mirror of
https://github.com/openai/codex.git
synced 2026-02-05 00:13:42 +00:00
Compare commits
14 Commits
pr9012
...
queue/stee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dfd05b6c2 | ||
|
|
3c20ed8900 | ||
|
|
8ce2488dc2 | ||
|
|
1b26719958 | ||
|
|
6a57d7980b | ||
|
|
198289934f | ||
|
|
6709ad8975 | ||
|
|
cf515142b0 | ||
|
|
74b2238931 | ||
|
|
cc0b5e8504 | ||
|
|
8e49a2c0d1 | ||
|
|
af1ed2685e | ||
|
|
1a0e2e612b | ||
|
|
acfd94f625 |
3
.bazelignore
Normal file
3
.bazelignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Without this, Bazel will consider BUILD.bazel files in
|
||||
# .git/sl/origbackups (which can be populated by Sapling SCM).
|
||||
.git
|
||||
2
.github/workflows/Dockerfile.bazel
vendored
2
.github/workflows/Dockerfile.bazel
vendored
@@ -4,7 +4,7 @@ FROM ubuntu:24.04
|
||||
# initial debugging, but we should publish to a more proper location.
|
||||
#
|
||||
# docker buildx create --use
|
||||
# docker buildx build --platform linux/amd64 -f .github/workflows/Dockerfile.bazel -t mbolin491/codex-bazel:latest --push .
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -f .github/workflows/Dockerfile.bazel -t mbolin491/codex-bazel:latest --push .
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
17
.github/workflows/ci.bazelrc
vendored
17
.github/workflows/ci.bazelrc
vendored
@@ -2,14 +2,19 @@ common --remote_download_minimal
|
||||
common --nobuild_runfile_links
|
||||
common --keep_going
|
||||
|
||||
# Prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# Currently remote builds only work on Mac hosts, until we untangle the libc constraints mess on linux.
|
||||
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
|
||||
|
||||
# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both.
|
||||
# Linux crossbuilds don't work until we untangle the libc constraint mess.
|
||||
common:linux --config=remote
|
||||
common:linux --strategy=remote
|
||||
common:linux --platforms=//:rbe
|
||||
|
||||
# On mac, we can run all the build actions remotely but test actions locally.
|
||||
common:macos --config=remote
|
||||
common:macos --strategy=remote
|
||||
|
||||
# We have platform-specific tests, so execute the tests locally using the strongest sandboxing available on each platform.
|
||||
common:macos --strategy=TestRunner=darwin-sandbox,local
|
||||
# Note: linux-sandbox is stronger, but not available in GHA.
|
||||
common:linux --strategy=TestRunner=processwrapper-sandbox,local
|
||||
|
||||
common:windows --strategy=TestRunner=local
|
||||
|
||||
|
||||
18
BUILD.bazel
18
BUILD.bazel
@@ -11,19 +11,9 @@ platform(
|
||||
],
|
||||
)
|
||||
|
||||
platform(
|
||||
alias(
|
||||
name = "rbe",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:linux",
|
||||
"@bazel_tools//tools/cpp:clang",
|
||||
"@toolchains_llvm_bootstrapped//constraints/libc:gnu.2.28",
|
||||
],
|
||||
exec_properties = {
|
||||
# Ubuntu-based image that includes git, python3, dotslash, and other
|
||||
# tools that various integration tests need.
|
||||
# Verify at https://hub.docker.com/layers/mbolin491/codex-bazel/latest/images/sha256:8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb
|
||||
"container-image": "docker://docker.io/mbolin491/codex-bazel@sha256:8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb",
|
||||
"OSFamily": "Linux",
|
||||
},
|
||||
actual = "@rbe_platform",
|
||||
)
|
||||
|
||||
exports_files(["AGENTS.md"])
|
||||
|
||||
@@ -120,3 +120,9 @@ crate.annotation(
|
||||
deps = [":windows_import_lib"],
|
||||
)
|
||||
use_repo(crate, "crates")
|
||||
|
||||
rbe_platform_repository = use_repo_rule("//:rbe.bzl", "rbe_platform_repository")
|
||||
|
||||
rbe_platform_repository(
|
||||
name = "rbe_platform",
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from_date = "2024-10-01"
|
||||
to_date = "2024-10-15"
|
||||
target_app = "cli"
|
||||
|
||||
# Test announcement only for local build version until 2026-01-10 excluded (past)
|
||||
[[announcements]]
|
||||
content = "This is a test announcement"
|
||||
version_regex = "^0\\.0\\.0$"
|
||||
|
||||
@@ -7,6 +7,7 @@ pub enum TransportError {
|
||||
#[error("http {status}: {body:?}")]
|
||||
Http {
|
||||
status: StatusCode,
|
||||
url: Option<String>,
|
||||
headers: Option<HeaderMap>,
|
||||
body: Option<String>,
|
||||
},
|
||||
|
||||
@@ -131,6 +131,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
);
|
||||
}
|
||||
|
||||
let url = req.url.clone();
|
||||
let builder = self.build(req)?;
|
||||
let resp = builder.send().await.map_err(Self::map_error)?;
|
||||
let status = resp.status();
|
||||
@@ -140,6 +141,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
let body = String::from_utf8(bytes.to_vec()).ok();
|
||||
return Err(TransportError::Http {
|
||||
status,
|
||||
url: Some(url),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
});
|
||||
@@ -161,6 +163,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
);
|
||||
}
|
||||
|
||||
let url = req.url.clone();
|
||||
let builder = self.build(req)?;
|
||||
let resp = builder.send().await.map_err(Self::map_error)?;
|
||||
let status = resp.status();
|
||||
@@ -169,6 +172,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
let body = resp.text().await.ok();
|
||||
return Err(TransportError::Http {
|
||||
status,
|
||||
url: Some(url),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ codex_rust_crate(
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"prompt.md",
|
||||
],
|
||||
# This is a bit of a hack, but empirically, some of our integration tests
|
||||
# are relying on the presence of this file as a repo root marker. When
|
||||
# running tests locally, this "just works," but in remote execution,
|
||||
# the working directory is different and so the file is not found unless it
|
||||
# is explicitly added as test data.
|
||||
#
|
||||
# TODO(aibrahim): Update the tests so that `just bazel-remote-test` succeeds
|
||||
# without this workaround.
|
||||
test_data_extra = ["//:AGENTS.md"],
|
||||
integration_deps_extra = ["//codex-rs/core/tests/common:common"],
|
||||
test_tags = ["no-sandbox"],
|
||||
extra_binaries = [
|
||||
|
||||
7
codex-rs/core/hierarchical_agents_message.md
Normal file
7
codex-rs/core/hierarchical_agents_message.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders.
|
||||
|
||||
Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed.
|
||||
|
||||
Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise.
|
||||
|
||||
When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content.
|
||||
@@ -25,11 +25,13 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: message,
|
||||
url: None,
|
||||
request_id: None,
|
||||
}),
|
||||
ApiError::Transport(transport) => match transport {
|
||||
TransportError::Http {
|
||||
status,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
} => {
|
||||
@@ -71,6 +73,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: body_text,
|
||||
url,
|
||||
request_id: extract_request_id(headers.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -533,6 +533,7 @@ async fn handle_unauthorized(
|
||||
fn map_unauthorized_status(status: StatusCode) -> CodexErr {
|
||||
map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status,
|
||||
url: None,
|
||||
headers: None,
|
||||
body: None,
|
||||
}))
|
||||
|
||||
@@ -1540,6 +1540,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the input if there was no task running to inject into
|
||||
pub async fn inject_response_items(
|
||||
&self,
|
||||
input: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
for item in input {
|
||||
ts.push_pending_input(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
|
||||
@@ -277,6 +277,7 @@ pub enum RefreshTokenFailedReason {
|
||||
pub struct UnexpectedResponseError {
|
||||
pub status: StatusCode,
|
||||
pub body: String,
|
||||
pub url: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -293,7 +294,11 @@ impl UnexpectedResponseError {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {})", self.status);
|
||||
let status = self.status;
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
@@ -307,16 +312,16 @@ impl std::fmt::Display for UnexpectedResponseError {
|
||||
if let Some(friendly) = self.friendly_message() {
|
||||
write!(f, "{friendly}")
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"unexpected status {}: {}{}",
|
||||
self.status,
|
||||
self.body,
|
||||
self.request_id
|
||||
.as_ref()
|
||||
.map(|id| format!(", request id: {id}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
let status = self.status;
|
||||
let body = &self.body;
|
||||
let mut message = format!("unexpected status {status}: {body}");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,12 +831,16 @@ mod tests {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
|
||||
.to_string(),
|
||||
url: Some("http://example.com/blocked".to_string()),
|
||||
request_id: Some("ray-id".to_string()),
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/blocked";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), request id: ray-id")
|
||||
format!(
|
||||
"{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, request id: ray-id"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -840,12 +849,14 @@ mod tests {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "plain text error".to_string(),
|
||||
url: Some("http://example.com/plain".to_string()),
|
||||
request_id: None,
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/plain";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("unexpected status {status}: plain text error")
|
||||
format!("unexpected status {status}: plain text error, url: {url}")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::models::is_image_close_tag_text;
|
||||
use codex_protocol::models::is_image_open_tag_text;
|
||||
use codex_protocol::models::is_local_image_close_tag_text;
|
||||
use codex_protocol::models::is_local_image_open_tag_text;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
@@ -32,9 +36,17 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
|
||||
let mut content: Vec<UserInput> = Vec::new();
|
||||
|
||||
for content_item in message.iter() {
|
||||
for (idx, content_item) in message.iter().enumerate() {
|
||||
match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
if (is_local_image_open_tag_text(text) || is_image_open_tag_text(text))
|
||||
&& (matches!(message.get(idx + 1), Some(ContentItem::InputImage { .. })))
|
||||
|| (idx > 0
|
||||
&& (is_local_image_close_tag_text(text) || is_image_close_tag_text(text))
|
||||
&& matches!(message.get(idx - 1), Some(ContentItem::InputImage { .. })))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return None;
|
||||
}
|
||||
@@ -177,6 +189,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_local_image_label_text() {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
let label = codex_protocol::models::local_image_open_tag_text(1);
|
||||
let user_text = "Please review this image.".to_string();
|
||||
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText { text: label },
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "</image>".to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: user_text.clone(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected user message turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Text { text: user_text },
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
other => panic!("expected TurnItem::UserMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unnamed_image_label_text() {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
let label = codex_protocol::models::image_open_tag_text();
|
||||
let user_text = "Please review this image.".to_string();
|
||||
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText { text: label },
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::image_close_tag_text(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: user_text.clone(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected user message turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Text { text: user_text },
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
other => panic!("expected TurnItem::UserMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_user_instructions_and_env() {
|
||||
let items = vec![
|
||||
|
||||
@@ -86,6 +86,8 @@ pub enum Feature {
|
||||
RemoteModels,
|
||||
/// Experimental shell snapshotting.
|
||||
ShellSnapshot,
|
||||
/// Append additional AGENTS.md guidance to user instructions.
|
||||
HierarchicalAgents,
|
||||
/// Experimental TUI v2 (viewport) implementation.
|
||||
Tui2,
|
||||
/// Enforce UTF8 output in Powershell.
|
||||
@@ -352,6 +354,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::HierarchicalAgents,
|
||||
key: "hierarchical_agents",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ApplyPatchFreeform,
|
||||
key: "apply_patch_freeform",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! 3. We do **not** walk past the Git root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::features::Feature;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::render_skills_section;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
@@ -21,6 +22,9 @@ use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
|
||||
include_str!("../hierarchical_agents_message.md");
|
||||
|
||||
/// Default filename scanned for project-level docs.
|
||||
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
|
||||
/// Preferred local override for project-level docs.
|
||||
@@ -36,35 +40,46 @@ pub(crate) async fn get_user_instructions(
|
||||
config: &Config,
|
||||
skills: Option<&[SkillMetadata]>,
|
||||
) -> Option<String> {
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
let project_docs = read_project_docs(config).await;
|
||||
|
||||
let project_docs = match read_project_docs(config).await {
|
||||
Ok(docs) => docs,
|
||||
let mut output = String::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
output.push_str(&instructions);
|
||||
}
|
||||
|
||||
match project_docs {
|
||||
Ok(Some(docs)) => {
|
||||
if !output.is_empty() {
|
||||
output.push_str(PROJECT_DOC_SEPARATOR);
|
||||
}
|
||||
output.push_str(&docs);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find project doc: {e:#}");
|
||||
return config.user_instructions.clone();
|
||||
}
|
||||
};
|
||||
|
||||
let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section);
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
parts.push(instructions);
|
||||
}
|
||||
|
||||
if let Some(project_doc) = combined_project_docs {
|
||||
if !parts.is_empty() {
|
||||
parts.push(PROJECT_DOC_SEPARATOR.to_string());
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
if let Some(skills_section) = skills_section {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
parts.push(project_doc);
|
||||
output.push_str(&skills_section);
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
if config.features.enabled(Feature::HierarchicalAgents) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
Some(output)
|
||||
} else {
|
||||
Some(parts.concat())
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,18 +232,6 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
|
||||
names
|
||||
}
|
||||
|
||||
fn merge_project_docs_with_skills(
|
||||
project_doc: Option<String>,
|
||||
skills_section: Option<String>,
|
||||
) -> Option<String> {
|
||||
match (project_doc, skills_section) {
|
||||
(Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
|
||||
(Some(doc), None) => Some(doc),
|
||||
(None, Some(skills)) => Some(skills),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -11,7 +11,9 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::local_image_content_items_with_label_number;
|
||||
|
||||
pub struct ViewImageHandler;
|
||||
|
||||
@@ -63,8 +65,15 @@ impl ToolHandler for ViewImageHandler {
|
||||
}
|
||||
let event_path = abs_path.clone();
|
||||
|
||||
let content: Vec<ContentItem> =
|
||||
local_image_content_items_with_label_number(&abs_path, None);
|
||||
let input = ResponseInputItem::Message {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
};
|
||||
|
||||
session
|
||||
.inject_input(vec![UserInput::LocalImage { path: abs_path }])
|
||||
.inject_response_items(vec![input])
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FunctionCallError::RespondToModel(
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::registry::ToolRegistryBuilder;
|
||||
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
|
||||
use codex_protocol::openai_models::ApplyPatchToolType;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -412,10 +413,9 @@ fn create_view_image_tool() -> ToolSpec {
|
||||
)]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "view_image".to_string(),
|
||||
description:
|
||||
"Attach a local image (by filesystem path) to the thread context for this turn."
|
||||
.to_string(),
|
||||
name: VIEW_IMAGE_TOOL_NAME.to_string(),
|
||||
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
|
||||
71
codex-rs/core/tests/suite/hierarchical_agents.rs
Normal file
71
codex-rs/core/tests/suite/hierarchical_agents.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use codex_core::features::Feature;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
|
||||
const HIERARCHICAL_AGENTS_SNIPPET: &str =
|
||||
"Files called AGENTS.md commonly appear in many places inside a container";
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("../fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
|
||||
let server = start_mock_server().await;
|
||||
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::HierarchicalAgents);
|
||||
std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md");
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
test.submit_turn("hello").await.expect("submit turn");
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let user_messages = request.message_input_texts("user");
|
||||
let instructions = user_messages
|
||||
.iter()
|
||||
.find(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
.expect("instructions message");
|
||||
assert!(
|
||||
instructions.contains("be nice"),
|
||||
"expected AGENTS.md text included: {instructions}"
|
||||
);
|
||||
let snippet_pos = instructions
|
||||
.find(HIERARCHICAL_AGENTS_SNIPPET)
|
||||
.expect("expected hierarchical agents snippet");
|
||||
let base_pos = instructions
|
||||
.find("be nice")
|
||||
.expect("expected AGENTS.md text");
|
||||
assert!(
|
||||
snippet_pos > base_pos,
|
||||
"expected hierarchical agents message appended after base instructions: {instructions}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hierarchical_agents_emits_when_no_project_doc() {
|
||||
let server = start_mock_server().await;
|
||||
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::HierarchicalAgents);
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
test.submit_turn("hello").await.expect("submit turn");
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let user_messages = request.message_input_texts("user");
|
||||
let instructions = user_messages
|
||||
.iter()
|
||||
.find(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
.expect("instructions message");
|
||||
assert!(
|
||||
instructions.contains(HIERARCHICAL_AGENTS_SNIPPET),
|
||||
"expected hierarchical agents message appended: {instructions}"
|
||||
);
|
||||
}
|
||||
183
codex-rs/core/tests/suite/mid_turn_input.rs
Normal file
183
codex-rs/core/tests/suite/mid_turn_input.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum MidTurnOp {
|
||||
UserInput,
|
||||
UserTurn,
|
||||
}
|
||||
|
||||
fn message_contains_text(item: &Value, text: &str) -> bool {
|
||||
item.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& item.get("role").and_then(Value::as_str) == Some("user")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.map(|content| {
|
||||
content.iter().any(|span| {
|
||||
span.get("type").and_then(Value::as_str) == Some("input_text")
|
||||
&& span.get("text").and_then(Value::as_str) == Some(text)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn run_mid_turn_injection_test(mid_turn_op: MidTurnOp) -> Result<()> {
|
||||
let harness = TestCodexHarness::new().await?;
|
||||
let test = harness.test();
|
||||
let codex = test.codex.clone();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
|
||||
let call_id = "shell-mid-turn";
|
||||
let first_message = "first message";
|
||||
let mid_turn_message = "mid-turn message";
|
||||
let workdir = cwd.to_string_lossy().to_string();
|
||||
|
||||
let args = json!({
|
||||
"command": ["bash", "-lc", "sleep 2; echo finished"],
|
||||
"workdir": workdir,
|
||||
"timeout_ms": 10_000,
|
||||
});
|
||||
|
||||
let first_response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
let second_response = sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "follow up"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
|
||||
mount_sse_once(harness.server(), first_response).await;
|
||||
let request_log = mount_sse_once(harness.server(), second_response).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: first_message.to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let _ = wait_for_event_match(&codex, |event| match event {
|
||||
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match mid_turn_op {
|
||||
MidTurnOp::UserInput => {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: mid_turn_message.to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
MidTurnOp::UserTurn => {
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: mid_turn_message.to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let end_event = wait_for_event_match(&codex, |event| match event {
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(end_event.exit_code, 0);
|
||||
assert!(
|
||||
end_event.stdout.contains("finished"),
|
||||
"expected stdout to include finished: {}",
|
||||
end_event.stdout
|
||||
);
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = request_log.single_request();
|
||||
let user_messages = request.message_input_texts("user");
|
||||
assert_eq!(
|
||||
user_messages,
|
||||
vec![first_message.to_string(), mid_turn_message.to_string()]
|
||||
);
|
||||
|
||||
let input = request.input();
|
||||
let tool_index = input
|
||||
.iter()
|
||||
.position(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||||
})
|
||||
.expect("expected function_call_output in request");
|
||||
let mid_turn_index = input
|
||||
.iter()
|
||||
.position(|item| message_contains_text(item, mid_turn_message))
|
||||
.expect("expected mid-turn user message in request");
|
||||
assert!(
|
||||
tool_index < mid_turn_index,
|
||||
"expected tool output before mid-turn input"
|
||||
);
|
||||
|
||||
let tool_output = request
|
||||
.function_call_output_text(call_id)
|
||||
.expect("expected function_call_output output text");
|
||||
assert!(
|
||||
tool_output.contains("finished"),
|
||||
"expected tool output to include finished: {tool_output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mid_turn_input_inserts_user_input_after_tool_output() -> Result<()> {
|
||||
run_mid_turn_injection_test(MidTurnOp::UserInput).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mid_turn_input_inserts_user_turn_after_tool_output() -> Result<()> {
|
||||
run_mid_turn_injection_test(MidTurnOp::UserTurn).await
|
||||
}
|
||||
@@ -30,6 +30,7 @@ mod exec;
|
||||
mod exec_policy;
|
||||
mod fork_thread;
|
||||
mod grep_files;
|
||||
mod hierarchical_agents;
|
||||
mod items;
|
||||
mod json_result;
|
||||
mod list_dir;
|
||||
|
||||
@@ -4,9 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::error::CodexErr;
|
||||
@@ -39,6 +37,8 @@ use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -98,19 +98,19 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
|
||||
let RemoteModelsHarness {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
..
|
||||
} = harness;
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let models_manager = thread_manager.get_models_manager();
|
||||
let available_model =
|
||||
@@ -214,16 +214,19 @@ async fn remote_models_truncation_policy_without_override_preserves_remote() ->
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let models_manager = harness.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &harness.config).await;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &test.config).await;
|
||||
|
||||
let model_info = models_manager
|
||||
.construct_model_info(slug, &harness.config)
|
||||
.construct_model_info(slug, &test.config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
model_info.truncation_policy,
|
||||
@@ -258,17 +261,20 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<(
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
config.tool_output_token_limit = Some(50);
|
||||
})
|
||||
.await?;
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
config.tool_output_token_limit = Some(50);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let models_manager = harness.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &harness.config).await;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &test.config).await;
|
||||
|
||||
let model_info = models_manager
|
||||
.construct_model_info(slug, &harness.config)
|
||||
.construct_model_info(slug, &test.config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
model_info.truncation_policy,
|
||||
@@ -335,19 +341,19 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
|
||||
let RemoteModelsHarness {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
..
|
||||
} = harness;
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let models_manager = thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, model, &config).await;
|
||||
@@ -577,50 +583,6 @@ async fn wait_for_model_available(
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteModelsHarness {
|
||||
codex: Arc<CodexThread>,
|
||||
cwd: Arc<TempDir>,
|
||||
config: Config,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
}
|
||||
|
||||
// todo(aibrahim): move this to with_model_provier in test_codex
|
||||
async fn build_remote_models_harness<F>(
|
||||
server: &MockServer,
|
||||
mutate_config: F,
|
||||
) -> Result<RemoteModelsHarness>
|
||||
where
|
||||
F: FnOnce(&mut Config),
|
||||
{
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let cwd = Arc::new(TempDir::new()?);
|
||||
|
||||
let mut config = load_default_config_for_test(&home).await;
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
config.model_provider = provider.clone();
|
||||
|
||||
mutate_config(&mut config);
|
||||
|
||||
let thread_manager = ThreadManager::with_models_provider(auth, provider);
|
||||
let thread_manager = Arc::new(thread_manager);
|
||||
|
||||
let new_conversation = thread_manager.start_thread(config.clone()).await?;
|
||||
|
||||
Ok(RemoteModelsHarness {
|
||||
codex: new_conversation.thread,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
})
|
||||
}
|
||||
|
||||
fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo {
|
||||
test_remote_model_with_policy(
|
||||
slug,
|
||||
|
||||
@@ -216,6 +216,20 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
|
||||
let image_message =
|
||||
find_image_message(&body).expect("pending input image message not included in request");
|
||||
let content_items = image_message
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.expect("image message has content array");
|
||||
assert_eq!(
|
||||
content_items.len(),
|
||||
1,
|
||||
"view_image should inject only the image content item (no tag/label text)"
|
||||
);
|
||||
assert_eq!(
|
||||
content_items[0].get("type").and_then(Value::as_str),
|
||||
Some("input_image"),
|
||||
"view_image should inject only an input_image content item"
|
||||
);
|
||||
let image_url = image_message
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
|
||||
@@ -180,6 +180,48 @@ fn local_image_error_placeholder(
|
||||
}
|
||||
}
|
||||
|
||||
pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
|
||||
|
||||
const IMAGE_OPEN_TAG: &str = "<image>";
|
||||
const IMAGE_CLOSE_TAG: &str = "</image>";
|
||||
const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
|
||||
const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
|
||||
const LOCAL_IMAGE_CLOSE_TAG: &str = IMAGE_CLOSE_TAG;
|
||||
|
||||
pub fn image_open_tag_text() -> String {
|
||||
IMAGE_OPEN_TAG.to_string()
|
||||
}
|
||||
|
||||
pub fn image_close_tag_text() -> String {
|
||||
IMAGE_CLOSE_TAG.to_string()
|
||||
}
|
||||
|
||||
pub fn local_image_label_text(label_number: usize) -> String {
|
||||
format!("[Image #{label_number}]")
|
||||
}
|
||||
|
||||
pub fn local_image_open_tag_text(label_number: usize) -> String {
|
||||
let label = local_image_label_text(label_number);
|
||||
format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
|
||||
}
|
||||
|
||||
pub fn is_local_image_open_tag_text(text: &str) -> bool {
|
||||
text.strip_prefix(LOCAL_IMAGE_OPEN_TAG_PREFIX)
|
||||
.is_some_and(|rest| rest.ends_with(LOCAL_IMAGE_OPEN_TAG_SUFFIX))
|
||||
}
|
||||
|
||||
pub fn is_local_image_close_tag_text(text: &str) -> bool {
|
||||
is_image_close_tag_text(text)
|
||||
}
|
||||
|
||||
pub fn is_image_open_tag_text(text: &str) -> bool {
|
||||
text == IMAGE_OPEN_TAG
|
||||
}
|
||||
|
||||
pub fn is_image_close_tag_text(text: &str) -> bool {
|
||||
text == IMAGE_CLOSE_TAG
|
||||
}
|
||||
|
||||
fn invalid_image_error_placeholder(
|
||||
path: &std::path::Path,
|
||||
error: impl std::fmt::Display,
|
||||
@@ -203,6 +245,53 @@ fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> Co
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_image_content_items_with_label_number(
|
||||
path: &std::path::Path,
|
||||
label_number: Option<usize>,
|
||||
) -> Vec<ContentItem> {
|
||||
match load_and_resize_to_fit(path) {
|
||||
Ok(image) => {
|
||||
let mut items = Vec::with_capacity(3);
|
||||
if let Some(label_number) = label_number {
|
||||
items.push(ContentItem::InputText {
|
||||
text: local_image_open_tag_text(label_number),
|
||||
});
|
||||
}
|
||||
items.push(ContentItem::InputImage {
|
||||
image_url: image.into_data_url(),
|
||||
});
|
||||
if label_number.is_some() {
|
||||
items.push(ContentItem::InputText {
|
||||
text: LOCAL_IMAGE_CLOSE_TAG.to_string(),
|
||||
});
|
||||
}
|
||||
items
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(&err, ImageProcessingError::Read { .. }) {
|
||||
vec![local_image_error_placeholder(path, &err)]
|
||||
} else if err.is_invalid_image() {
|
||||
vec![invalid_image_error_placeholder(path, &err)]
|
||||
} else {
|
||||
let Some(mime_guess) = mime_guess::from_path(path).first() else {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
"unsupported MIME type (unknown)",
|
||||
)];
|
||||
};
|
||||
let mime = mime_guess.essence_str().to_owned();
|
||||
if !mime.starts_with("image/") {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
format!("unsupported MIME type `{mime}`"),
|
||||
)];
|
||||
}
|
||||
vec![unsupported_image_error_placeholder(path, &mime)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResponseInputItem> for ResponseItem {
|
||||
fn from(item: ResponseInputItem) -> Self {
|
||||
match item {
|
||||
@@ -296,41 +385,27 @@ pub enum ReasoningItemContent {
|
||||
|
||||
impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
fn from(items: Vec<UserInput>) -> Self {
|
||||
let mut image_index = 0;
|
||||
Self::Message {
|
||||
role: "user".to_string(),
|
||||
content: items
|
||||
.into_iter()
|
||||
.filter_map(|c| match c {
|
||||
UserInput::Text { text } => Some(ContentItem::InputText { text }),
|
||||
UserInput::Image { image_url } => Some(ContentItem::InputImage { image_url }),
|
||||
UserInput::LocalImage { path } => match load_and_resize_to_fit(&path) {
|
||||
Ok(image) => Some(ContentItem::InputImage {
|
||||
image_url: image.into_data_url(),
|
||||
}),
|
||||
Err(err) => {
|
||||
if matches!(&err, ImageProcessingError::Read { .. }) {
|
||||
Some(local_image_error_placeholder(&path, &err))
|
||||
} else if err.is_invalid_image() {
|
||||
Some(invalid_image_error_placeholder(&path, &err))
|
||||
} else {
|
||||
let Some(mime_guess) = mime_guess::from_path(&path).first() else {
|
||||
return Some(local_image_error_placeholder(
|
||||
&path,
|
||||
"unsupported MIME type (unknown)",
|
||||
));
|
||||
};
|
||||
let mime = mime_guess.essence_str().to_owned();
|
||||
if !mime.starts_with("image/") {
|
||||
return Some(local_image_error_placeholder(
|
||||
&path,
|
||||
format!("unsupported MIME type `{mime}`"),
|
||||
));
|
||||
}
|
||||
Some(unsupported_image_error_placeholder(&path, &mime))
|
||||
}
|
||||
}
|
||||
},
|
||||
UserInput::Skill { .. } => None, // Skill bodies are injected later in core
|
||||
.flat_map(|c| match c {
|
||||
UserInput::Text { text } => vec![ContentItem::InputText { text }],
|
||||
UserInput::Image { image_url } => vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage { image_url },
|
||||
ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
},
|
||||
],
|
||||
UserInput::LocalImage { path } => {
|
||||
image_index += 1;
|
||||
local_image_content_items_with_label_number(&path, Some(image_index))
|
||||
}
|
||||
UserInput::Skill { .. } => Vec::new(), // Skill bodies are injected later in core
|
||||
})
|
||||
.collect::<Vec<ContentItem>>(),
|
||||
}
|
||||
@@ -770,6 +845,33 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_image_user_input_with_tags() -> Result<()> {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
}]);
|
||||
|
||||
match item {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
let expected = vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage { image_url },
|
||||
ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
},
|
||||
];
|
||||
assert_eq!(content, expected);
|
||||
}
|
||||
other => panic!("expected message response but got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_image_read_error_adds_placeholder() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::style::user_message_style;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -59,7 +60,7 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -79,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Queued(String),
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
None,
|
||||
@@ -100,6 +102,12 @@ enum PromptSelectionAction {
|
||||
Submit { text: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum SubmitMode {
|
||||
Submit,
|
||||
Queue,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -274,10 +282,12 @@ impl ChatComposer {
|
||||
// normalize_pasted_path already handles Windows → WSL path conversion,
|
||||
// so we can directly try to read the image dimensions.
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((w, h)) => {
|
||||
Ok((width, height)) => {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format_label = pasted_image_format(&path_buf).label();
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
tracing::debug!("image dimensions={}x{}", width, height);
|
||||
let format = pasted_image_format(&path_buf);
|
||||
tracing::debug!("attached image format={}", format.label());
|
||||
self.attach_image(path_buf);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -408,13 +418,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// 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 file_label = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let base_placeholder = format!("{file_label} {width}x{height}");
|
||||
let placeholder = self.next_image_placeholder(&base_placeholder);
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = local_image_label_text(image_number);
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
// styled distinctly and treated atomically for cursor/mutations.
|
||||
self.textarea.insert_element(&placeholder);
|
||||
@@ -477,22 +483,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_image_placeholder(&mut self, base: &str) -> String {
|
||||
let text = self.textarea.text();
|
||||
let mut suffix = 1;
|
||||
loop {
|
||||
let placeholder = if suffix == 1 {
|
||||
format!("[{base}]")
|
||||
} else {
|
||||
format!("[{base} #{suffix}]")
|
||||
};
|
||||
if !text.contains(&placeholder) {
|
||||
return placeholder;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.textarea.insert_str(text);
|
||||
self.sync_popups();
|
||||
@@ -818,47 +808,43 @@ impl ChatComposer {
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((width, height)) => {
|
||||
tracing::debug!("selected image dimensions={}x{}", width, height);
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = safe_cursor + end_rel_idx;
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = safe_cursor + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("image dimensions lookup failed: {err}");
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-image: inserting file path.
|
||||
@@ -1219,161 +1205,171 @@ impl ChatComposer {
|
||||
}
|
||||
self.handle_input_basic(key_event)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => self.handle_submit(SubmitMode::Queue),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input_starts_with_space
|
||||
&& let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
}
|
||||
} => self.handle_submit(SubmitMode::Submit),
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_submit(&mut self, mode: SubmitMode) -> (InputResult, bool) {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input_starts_with_space
|
||||
&& let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
let result = match mode {
|
||||
SubmitMode::Submit => InputResult::Submitted(text),
|
||||
SubmitMode::Queue => InputResult::Queued(text),
|
||||
};
|
||||
(result, true)
|
||||
}
|
||||
|
||||
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||||
match self.paste_burst.flush_if_due(now) {
|
||||
FlushResult::Paste(pasted) => {
|
||||
@@ -1464,20 +1460,29 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Special handling for backspace on placeholders
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} = input
|
||||
&& self.try_remove_any_placeholder_at_cursor()
|
||||
// Backspace at the start of an image placeholder should delete that placeholder (rather
|
||||
// than deleting content before it). Do this without scanning the full text by consulting
|
||||
// the textarea's element list.
|
||||
if matches!(input.code, KeyCode::Backspace)
|
||||
&& self.try_remove_image_element_at_cursor_start()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||||
let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.textarea.element_payloads())
|
||||
};
|
||||
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
}
|
||||
|
||||
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
|
||||
let crossterm::event::KeyEvent {
|
||||
@@ -1499,176 +1504,69 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any placeholders were removed and remove their corresponding pending pastes
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
// Keep attached images in proportion to how many matching placeholders exist in the text.
|
||||
// This handles duplicate placeholders that share the same visible label.
|
||||
if !self.attached_images.is_empty() {
|
||||
let mut needed: HashMap<String, usize> = HashMap::new();
|
||||
for img in &self.attached_images {
|
||||
needed
|
||||
.entry(img.placeholder.clone())
|
||||
.or_insert_with(|| text_after.matches(&img.placeholder).count());
|
||||
}
|
||||
|
||||
let mut used: HashMap<String, usize> = HashMap::new();
|
||||
let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
|
||||
for img in self.attached_images.drain(..) {
|
||||
let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
|
||||
let used_count = used.entry(img.placeholder.clone()).or_insert(0);
|
||||
if *used_count < total_needed {
|
||||
kept.push(img);
|
||||
*used_count += 1;
|
||||
}
|
||||
}
|
||||
self.attached_images = kept;
|
||||
}
|
||||
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
|
||||
/// Returns true if a placeholder was removed.
|
||||
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
|
||||
// Clamp the cursor to a valid char boundary to avoid panics when slicing.
|
||||
let text = self.textarea.text();
|
||||
let p = Self::clamp_to_char_boundary(text, self.textarea.cursor());
|
||||
|
||||
// Try image placeholders first
|
||||
let mut out: Option<(usize, String)> = None;
|
||||
// Detect if the cursor is at the end of any image placeholder.
|
||||
// If duplicates exist, remove the specific occurrence's mapping.
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p < ph.len() {
|
||||
continue;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text.get(start..p) != Some(ph.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count the number of occurrences of `ph` before `start`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < start {
|
||||
let segment = match text.get(search_pos..start) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if let Some(found) = segment.find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the occ_before-th attached image that shares this placeholder label.
|
||||
out = if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
Some((remove_idx, ph.clone()))
|
||||
} else {
|
||||
Some((i, ph.clone()))
|
||||
};
|
||||
break;
|
||||
}
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
|
||||
if self.attached_images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of an image placeholder.
|
||||
// let result = 'out: {
|
||||
let out: Option<(usize, String)> = 'out: {
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p + ph.len() > text.len() {
|
||||
continue;
|
||||
}
|
||||
if text.get(p..p + ph.len()) != Some(ph.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count occurrences of `ph` before `p`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < p {
|
||||
let segment = match text.get(search_pos..p) {
|
||||
Some(s) => s,
|
||||
None => break 'out None,
|
||||
};
|
||||
if let Some(found) = segment.find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break 'out None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
break 'out Some((remove_idx, ph.clone()));
|
||||
} else {
|
||||
break 'out Some((i, ph.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
let p = self.textarea.cursor();
|
||||
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
|
||||
return false;
|
||||
};
|
||||
let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == payload)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
self.textarea.replace_range(p..p + payload.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
|
||||
let mut removed_any_image = false;
|
||||
for removed in elements_before
|
||||
.into_iter()
|
||||
.filter(|payload| !elements_after.contains(payload))
|
||||
{
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &removed);
|
||||
|
||||
if let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == removed)
|
||||
{
|
||||
self.attached_images.remove(idx);
|
||||
removed_any_image = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Then try pasted-content placeholders
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text.get(start..p) == Some(ph.as_str()) {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
if removed_any_image {
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of a pasted-content placeholder.
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p + ph.len() > text.len() {
|
||||
return None;
|
||||
fn relabel_attached_images_and_update_placeholders(&mut self) {
|
||||
for idx in 0..self.attached_images.len() {
|
||||
let expected = local_image_label_text(idx + 1);
|
||||
let current = self.attached_images[idx].placeholder.clone();
|
||||
if current == expected {
|
||||
continue;
|
||||
}
|
||||
if text.get(p..p + ph.len()) == Some(ph.as_str()) {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
|
||||
self.attached_images[idx].placeholder = expected.clone();
|
||||
let _renamed = self.textarea.replace_element_payload(¤t, &expected);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||||
@@ -3477,12 +3375,12 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3501,11 +3399,11 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3526,21 +3424,15 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image_dup.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
assert!(text.contains("[image_dup.png 10x5]"));
|
||||
assert!(text.contains("[image_dup.png 10x5 #2]"));
|
||||
assert_eq!(
|
||||
composer.attached_images[0].placeholder,
|
||||
"[image_dup.png 10x5]"
|
||||
);
|
||||
assert_eq!(
|
||||
composer.attached_images[1].placeholder,
|
||||
"[image_dup.png 10x5 #2]"
|
||||
);
|
||||
assert!(text.contains("[Image #1]"));
|
||||
assert!(text.contains("[Image #2]"));
|
||||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||||
assert_eq!(composer.attached_images[1].placeholder, "[Image #2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3555,7 +3447,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
@@ -3566,7 +3458,7 @@ mod tests {
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
composer.attach_image(path, 20, 10, "PNG");
|
||||
composer.attach_image(path);
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
@@ -3598,7 +3490,7 @@ mod tests {
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
// Add multibyte text after the placeholder
|
||||
composer.textarea.insert_str("日本語");
|
||||
|
||||
@@ -3607,16 +3499,11 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[image_multibyte.png 10x5]")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
||||
fn deleting_one_of_duplicate_image_placeholders_removes_one_entry() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -3630,10 +3517,10 @@ mod tests {
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
|
||||
composer.attach_image(path1, 10, 5, "PNG");
|
||||
composer.attach_image(path1);
|
||||
// separate placeholders with a space for clarity
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path2.clone());
|
||||
|
||||
let placeholder1 = composer.attached_images[0].placeholder.clone();
|
||||
let placeholder2 = composer.attached_images[1].placeholder.clone();
|
||||
@@ -3647,25 +3534,66 @@ mod tests {
|
||||
|
||||
let new_text = composer.textarea.text().to_string();
|
||||
assert_eq!(
|
||||
0,
|
||||
1,
|
||||
new_text.matches(&placeholder1).count(),
|
||||
"first placeholder removed"
|
||||
"one placeholder remains after deletion"
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
new_text.matches(&placeholder2).count(),
|
||||
"second placeholder was relabeled"
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
new_text.matches(&placeholder2).count(),
|
||||
"second placeholder remains"
|
||||
new_text.matches("[Image #1]").count(),
|
||||
"remaining placeholder relabeled to #1"
|
||||
);
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: "[image_dup2.png 10x5]".to_string()
|
||||
placeholder: "[Image #1]".to_string()
|
||||
}],
|
||||
composer.attached_images,
|
||||
"one image mapping remains"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_first_text_element_renumbers_following_text_element() {
|
||||
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 path1 = PathBuf::from("/tmp/image_first.png");
|
||||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||||
|
||||
// Insert two adjacent atomic elements.
|
||||
composer.attach_image(path1);
|
||||
composer.attach_image(path2.clone());
|
||||
assert_eq!(composer.textarea.text(), "[Image #1][Image #2]");
|
||||
assert_eq!(composer.attached_images.len(), 2);
|
||||
|
||||
// Delete the first element using normal textarea editing (Delete at cursor start).
|
||||
composer.textarea.set_cursor(0);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||||
|
||||
// Remaining image should be renumbered and the textarea element updated.
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert_eq!(composer.attached_images[0].path, path2);
|
||||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||||
assert_eq!(composer.textarea.text(), "[Image #1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_filepath_attaches_image() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
@@ -3686,12 +3614,7 @@ mod tests {
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
assert!(needs_redraw);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[codex_tui_test_paste_image.png 3x2] ")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1] "));
|
||||
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs, vec![tmp_path]);
|
||||
|
||||
@@ -266,6 +266,7 @@ enum ShortcutId {
|
||||
Commands,
|
||||
ShellCommands,
|
||||
InsertNewline,
|
||||
QueueMessage,
|
||||
FilePaths,
|
||||
PasteImage,
|
||||
ExternalEditor,
|
||||
@@ -372,6 +373,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
prefix: "",
|
||||
label: " for newline",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::QueueMessage,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('k')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to queue message",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::FilePaths,
|
||||
bindings: &[ShortcutBinding {
|
||||
|
||||
@@ -542,16 +542,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.composer.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2127
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline ctrl + enter to send immediately "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 468
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline ctrl + enter to send immediately "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
@@ -715,6 +715,88 @@ impl TextArea {
|
||||
|
||||
// ===== Text elements support =====
|
||||
|
||||
pub fn element_payloads(&self) -> Vec<String> {
|
||||
self.elements
|
||||
.iter()
|
||||
.filter_map(|e| self.text.get(e.range.clone()).map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
|
||||
let pos = pos.min(self.text.len());
|
||||
let elem = self.elements.iter().find(|e| e.range.start == pos)?;
|
||||
self.text.get(elem.range.clone()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Renames a single text element in-place, keeping it atomic.
|
||||
///
|
||||
/// This is intended for cases where the element payload is an identifier (e.g. a placeholder)
|
||||
/// that must be updated without converting the element back into normal text.
|
||||
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
|
||||
let Some(idx) = self
|
||||
.elements
|
||||
.iter()
|
||||
.position(|e| self.text.get(e.range.clone()) == Some(old))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let range = self.elements[idx].range.clone();
|
||||
let start = range.start;
|
||||
let end = range.end;
|
||||
if start > end || end > self.text.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let removed_len = end - start;
|
||||
let inserted_len = new.len();
|
||||
let diff = inserted_len as isize - removed_len as isize;
|
||||
|
||||
self.text.replace_range(range, new);
|
||||
self.wrap_cache.replace(None);
|
||||
self.preferred_col = None;
|
||||
|
||||
// Update the modified element's range.
|
||||
self.elements[idx].range = start..(start + inserted_len);
|
||||
|
||||
// Shift element ranges that occur after the replaced element.
|
||||
if diff != 0 {
|
||||
for (j, e) in self.elements.iter_mut().enumerate() {
|
||||
if j == idx {
|
||||
continue;
|
||||
}
|
||||
if e.range.end <= start {
|
||||
continue;
|
||||
}
|
||||
if e.range.start >= end {
|
||||
e.range.start = ((e.range.start as isize) + diff) as usize;
|
||||
e.range.end = ((e.range.end as isize) + diff) as usize;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Elements should not partially overlap each other; degrade gracefully by
|
||||
// snapping anything intersecting the replaced range to the new bounds.
|
||||
e.range.start = start.min(e.range.start);
|
||||
e.range.end = (start + inserted_len).max(e.range.end.saturating_add_signed(diff));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the cursor position to account for the edit.
|
||||
self.cursor_pos = if self.cursor_pos < start {
|
||||
self.cursor_pos
|
||||
} else if self.cursor_pos <= end {
|
||||
start + inserted_len
|
||||
} else {
|
||||
((self.cursor_pos as isize) + diff) as usize
|
||||
};
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
|
||||
// Keep element ordering deterministic.
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn insert_element(&mut self, text: &str) {
|
||||
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||||
self.insert_str_at(start, text);
|
||||
|
||||
@@ -1593,12 +1593,13 @@ impl ChatWidget {
|
||||
{
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
self.attach_image(
|
||||
path,
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label(),
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
@@ -1629,40 +1630,35 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// If a task is running, queue the user input to be sent after the turn completes.
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
_ => match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
InputResult::Queued(text) => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
tracing::info!(
|
||||
"attach_image path={path:?} width={width} height={height} format={format_label}",
|
||||
);
|
||||
self.bottom_pane
|
||||
.attach_image(path, width, height, format_label);
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -2012,14 +2008,14 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(UserInput::Text { text: text.clone() });
|
||||
}
|
||||
|
||||
for path in image_paths {
|
||||
items.push(UserInput::LocalImage { path });
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(UserInput::Text { text: text.clone() });
|
||||
}
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
let skill_mentions = find_skill_mentions(&text, skills);
|
||||
for skill in skill_mentions {
|
||||
|
||||
@@ -1057,7 +1057,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "repeat me");
|
||||
|
||||
// Queue the prompt while the task is running.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
}
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 3);
|
||||
@@ -1079,7 +1079,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1114,8 +1114,8 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
||||
|
||||
chat.bottom_pane.insert_str("draft message ");
|
||||
chat.bottom_pane
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png");
|
||||
let placeholder = "[preview.png 24x42]";
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"));
|
||||
let placeholder = "[Image #1]";
|
||||
assert!(
|
||||
chat.bottom_pane.composer_text().ends_with(placeholder),
|
||||
"expected placeholder {placeholder:?} in composer text"
|
||||
|
||||
@@ -54,7 +54,9 @@ impl ComposerInput {
|
||||
/// Feed a key event into the composer and return a high-level action.
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted(text) => ComposerAction::Submitted(text),
|
||||
InputResult::Submitted(text) | InputResult::Queued(text) => {
|
||||
ComposerAction::Submitted(text)
|
||||
}
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
|
||||
@@ -17,6 +17,7 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
use super::format::FieldFormatter;
|
||||
@@ -62,6 +63,7 @@ struct StatusHistoryCell {
|
||||
approval: String,
|
||||
sandbox: String,
|
||||
agents_summary: String,
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
@@ -129,6 +131,7 @@ impl StatusHistoryCell {
|
||||
}
|
||||
};
|
||||
let agents_summary = compose_agents_summary(config);
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let default_usage = TokenUsage::default();
|
||||
@@ -157,6 +160,7 @@ impl StatusHistoryCell {
|
||||
approval,
|
||||
sandbox,
|
||||
agents_summary,
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
token_usage,
|
||||
@@ -338,6 +342,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
|
||||
if self.model_provider.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Model provider");
|
||||
}
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
}
|
||||
@@ -381,6 +388,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
let directory_value = format_directory_display(&self.directory, Some(value_width));
|
||||
|
||||
lines.push(formatter.line("Model", model_spans));
|
||||
if let Some(model_provider) = self.model_provider.as_ref() {
|
||||
lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())]));
|
||||
}
|
||||
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
|
||||
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
|
||||
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
|
||||
@@ -416,3 +426,39 @@ impl HistoryCell for StatusHistoryCell {
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config) -> Option<String> {
|
||||
let provider = &config.model_provider;
|
||||
let name = provider.name.trim();
|
||||
let provider_name = if name.is_empty() {
|
||||
config.model_provider_id.as_str()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let base_url = provider.base_url.as_deref().and_then(sanitize_base_url);
|
||||
let is_default_openai = provider.is_openai() && base_url.is_none();
|
||||
if is_default_openai {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match base_url {
|
||||
Some(base_url) => format!("{provider_name} - {base_url}"),
|
||||
None => provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ use crate::style::user_message_style;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -62,7 +63,7 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -82,6 +83,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Queued(String),
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
None,
|
||||
@@ -103,6 +105,12 @@ enum PromptSelectionAction {
|
||||
Submit { text: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum SubmitMode {
|
||||
Submit,
|
||||
Queue,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -287,10 +295,12 @@ impl ChatComposer {
|
||||
// normalize_pasted_path already handles Windows → WSL path conversion,
|
||||
// so we can directly try to read the image dimensions.
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((w, h)) => {
|
||||
Ok((width, height)) => {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format_label = pasted_image_format(&path_buf).label();
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
tracing::debug!("image dimensions={}x{}", width, height);
|
||||
let format = pasted_image_format(&path_buf);
|
||||
tracing::debug!("attached image format={}", format.label());
|
||||
self.attach_image(path_buf);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -342,12 +352,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// 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 file_label = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let placeholder = format!("[{file_label} {width}x{height}]");
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = local_image_label_text(image_number);
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
// styled distinctly and treated atomically for cursor/mutations.
|
||||
self.textarea.insert_element(&placeholder);
|
||||
@@ -735,47 +742,43 @@ impl ChatComposer {
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((width, height)) => {
|
||||
tracing::debug!("selected image dimensions={}x{}", width, height);
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = safe_cursor + end_rel_idx;
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = safe_cursor + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("image dimensions lookup failed: {err}");
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-image: inserting file path.
|
||||
@@ -1136,161 +1139,171 @@ impl ChatComposer {
|
||||
}
|
||||
self.handle_input_basic(key_event)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => self.handle_submit(SubmitMode::Queue),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input_starts_with_space
|
||||
&& let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
}
|
||||
} => self.handle_submit(SubmitMode::Submit),
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_submit(&mut self, mode: SubmitMode) -> (InputResult, bool) {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input_starts_with_space
|
||||
&& let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
let result = match mode {
|
||||
SubmitMode::Submit => InputResult::Submitted(text),
|
||||
SubmitMode::Queue => InputResult::Queued(text),
|
||||
};
|
||||
(result, true)
|
||||
}
|
||||
|
||||
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||||
match self.paste_burst.flush_if_due(now) {
|
||||
FlushResult::Paste(pasted) => {
|
||||
@@ -1381,20 +1394,28 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Special handling for backspace on placeholders
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} = input
|
||||
&& self.try_remove_any_placeholder_at_cursor()
|
||||
// Backspace at the start of an image placeholder should delete that placeholder (rather
|
||||
// than deleting content before it). Do this without scanning the full text by consulting
|
||||
// the textarea's element list.
|
||||
if matches!(input.code, KeyCode::Backspace)
|
||||
&& self.try_remove_image_element_at_cursor_start()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||||
let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.textarea.element_payloads())
|
||||
};
|
||||
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
}
|
||||
|
||||
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
|
||||
let crossterm::event::KeyEvent {
|
||||
@@ -1416,176 +1437,69 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any placeholders were removed and remove their corresponding pending pastes
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
// Keep attached images in proportion to how many matching placeholders exist in the text.
|
||||
// This handles duplicate placeholders that share the same visible label.
|
||||
if !self.attached_images.is_empty() {
|
||||
let mut needed: HashMap<String, usize> = HashMap::new();
|
||||
for img in &self.attached_images {
|
||||
needed
|
||||
.entry(img.placeholder.clone())
|
||||
.or_insert_with(|| text_after.matches(&img.placeholder).count());
|
||||
}
|
||||
|
||||
let mut used: HashMap<String, usize> = HashMap::new();
|
||||
let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
|
||||
for img in self.attached_images.drain(..) {
|
||||
let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
|
||||
let used_count = used.entry(img.placeholder.clone()).or_insert(0);
|
||||
if *used_count < total_needed {
|
||||
kept.push(img);
|
||||
*used_count += 1;
|
||||
}
|
||||
}
|
||||
self.attached_images = kept;
|
||||
}
|
||||
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
|
||||
/// Returns true if a placeholder was removed.
|
||||
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
|
||||
// Clamp the cursor to a valid char boundary to avoid panics when slicing.
|
||||
let text = self.textarea.text();
|
||||
let p = Self::clamp_to_char_boundary(text, self.textarea.cursor());
|
||||
|
||||
// Try image placeholders first
|
||||
let mut out: Option<(usize, String)> = None;
|
||||
// Detect if the cursor is at the end of any image placeholder.
|
||||
// If duplicates exist, remove the specific occurrence's mapping.
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p < ph.len() {
|
||||
continue;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text.get(start..p) != Some(ph.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count the number of occurrences of `ph` before `start`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < start {
|
||||
let segment = match text.get(search_pos..start) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if let Some(found) = segment.find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the occ_before-th attached image that shares this placeholder label.
|
||||
out = if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
Some((remove_idx, ph.clone()))
|
||||
} else {
|
||||
Some((i, ph.clone()))
|
||||
};
|
||||
break;
|
||||
}
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
|
||||
if self.attached_images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of an image placeholder.
|
||||
// let result = 'out: {
|
||||
let out: Option<(usize, String)> = 'out: {
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p + ph.len() > text.len() {
|
||||
continue;
|
||||
}
|
||||
if text.get(p..p + ph.len()) != Some(ph.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count occurrences of `ph` before `p`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < p {
|
||||
let segment = match text.get(search_pos..p) {
|
||||
Some(s) => s,
|
||||
None => break 'out None,
|
||||
};
|
||||
if let Some(found) = segment.find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break 'out None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
break 'out Some((remove_idx, ph.clone()));
|
||||
} else {
|
||||
break 'out Some((i, ph.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
let p = self.textarea.cursor();
|
||||
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
|
||||
return false;
|
||||
};
|
||||
let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == payload)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
self.textarea.replace_range(p..p + payload.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
|
||||
let mut removed_any_image = false;
|
||||
for removed in elements_before
|
||||
.into_iter()
|
||||
.filter(|payload| !elements_after.contains(payload))
|
||||
{
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &removed);
|
||||
|
||||
if let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == removed)
|
||||
{
|
||||
self.attached_images.remove(idx);
|
||||
removed_any_image = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Then try pasted-content placeholders
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text.get(start..p) == Some(ph.as_str()) {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
if removed_any_image {
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of a pasted-content placeholder.
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p + ph.len() > text.len() {
|
||||
return None;
|
||||
fn relabel_attached_images_and_update_placeholders(&mut self) {
|
||||
for idx in 0..self.attached_images.len() {
|
||||
let expected = local_image_label_text(idx + 1);
|
||||
let current = self.attached_images[idx].placeholder.clone();
|
||||
if current == expected {
|
||||
continue;
|
||||
}
|
||||
if text.get(p..p + ph.len()) == Some(ph.as_str()) {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
|
||||
self.attached_images[idx].placeholder = expected.clone();
|
||||
let _renamed = self.textarea.replace_element_payload(¤t, &expected);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||||
@@ -3408,12 +3322,12 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3432,11 +3346,11 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3457,7 +3371,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
@@ -3468,7 +3382,7 @@ mod tests {
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
composer.attach_image(path, 20, 10, "PNG");
|
||||
composer.attach_image(path);
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
@@ -3500,7 +3414,7 @@ mod tests {
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
// Add multibyte text after the placeholder
|
||||
composer.textarea.insert_str("日本語");
|
||||
|
||||
@@ -3509,12 +3423,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[image_multibyte.png 10x5]")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3532,10 +3441,10 @@ mod tests {
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
|
||||
composer.attach_image(path1, 10, 5, "PNG");
|
||||
composer.attach_image(path1);
|
||||
// separate placeholders with a space for clarity
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path2.clone());
|
||||
|
||||
let placeholder1 = composer.attached_images[0].placeholder.clone();
|
||||
let placeholder2 = composer.attached_images[1].placeholder.clone();
|
||||
@@ -3550,24 +3459,60 @@ mod tests {
|
||||
let new_text = composer.textarea.text().to_string();
|
||||
assert_eq!(
|
||||
0,
|
||||
new_text.matches(&placeholder1).count(),
|
||||
"first placeholder removed"
|
||||
new_text.matches(&placeholder2).count(),
|
||||
"second placeholder was relabeled"
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
new_text.matches(&placeholder2).count(),
|
||||
"second placeholder remains"
|
||||
new_text.matches("[Image #1]").count(),
|
||||
"remaining placeholder relabeled to #1"
|
||||
);
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: "[image_dup2.png 10x5]".to_string()
|
||||
placeholder: "[Image #1]".to_string()
|
||||
}],
|
||||
composer.attached_images,
|
||||
"one image mapping remains"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_first_text_element_renumbers_following_text_element() {
|
||||
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 path1 = PathBuf::from("/tmp/image_first.png");
|
||||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||||
|
||||
// Insert two adjacent atomic elements.
|
||||
composer.attach_image(path1);
|
||||
composer.attach_image(path2.clone());
|
||||
assert_eq!(composer.textarea.text(), "[Image #1][Image #2]");
|
||||
assert_eq!(composer.attached_images.len(), 2);
|
||||
|
||||
// Delete the first element using normal textarea editing (Delete at cursor start).
|
||||
composer.textarea.set_cursor(0);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||||
|
||||
// Remaining image should be renumbered and the textarea element updated.
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert_eq!(composer.attached_images[0].path, path2);
|
||||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||||
assert_eq!(composer.textarea.text(), "[Image #1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_filepath_attaches_image() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
@@ -3588,12 +3533,7 @@ mod tests {
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
assert!(needs_redraw);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[codex_tui_test_paste_image.png 3x2] ")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1] "));
|
||||
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs, vec![tmp_path]);
|
||||
|
||||
@@ -307,6 +307,7 @@ enum ShortcutId {
|
||||
Commands,
|
||||
ShellCommands,
|
||||
InsertNewline,
|
||||
QueueMessage,
|
||||
FilePaths,
|
||||
PasteImage,
|
||||
EditPrevious,
|
||||
@@ -412,6 +413,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
prefix: "",
|
||||
label: " for newline",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::QueueMessage,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('k')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to queue message",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::FilePaths,
|
||||
bindings: &[ShortcutBinding {
|
||||
|
||||
@@ -529,16 +529,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.composer.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +715,88 @@ impl TextArea {
|
||||
|
||||
// ===== Text elements support =====
|
||||
|
||||
pub fn element_payloads(&self) -> Vec<String> {
|
||||
self.elements
|
||||
.iter()
|
||||
.filter_map(|e| self.text.get(e.range.clone()).map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
|
||||
let pos = pos.min(self.text.len());
|
||||
let elem = self.elements.iter().find(|e| e.range.start == pos)?;
|
||||
self.text.get(elem.range.clone()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Renames a single text element in-place, keeping it atomic.
|
||||
///
|
||||
/// This is intended for cases where the element payload is an identifier (e.g. a placeholder)
|
||||
/// that must be updated without converting the element back into normal text.
|
||||
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
|
||||
let Some(idx) = self
|
||||
.elements
|
||||
.iter()
|
||||
.position(|e| self.text.get(e.range.clone()) == Some(old))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let range = self.elements[idx].range.clone();
|
||||
let start = range.start;
|
||||
let end = range.end;
|
||||
if start > end || end > self.text.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let removed_len = end - start;
|
||||
let inserted_len = new.len();
|
||||
let diff = inserted_len as isize - removed_len as isize;
|
||||
|
||||
self.text.replace_range(range, new);
|
||||
self.wrap_cache.replace(None);
|
||||
self.preferred_col = None;
|
||||
|
||||
// Update the modified element's range.
|
||||
self.elements[idx].range = start..(start + inserted_len);
|
||||
|
||||
// Shift element ranges that occur after the replaced element.
|
||||
if diff != 0 {
|
||||
for (j, e) in self.elements.iter_mut().enumerate() {
|
||||
if j == idx {
|
||||
continue;
|
||||
}
|
||||
if e.range.end <= start {
|
||||
continue;
|
||||
}
|
||||
if e.range.start >= end {
|
||||
e.range.start = ((e.range.start as isize) + diff) as usize;
|
||||
e.range.end = ((e.range.end as isize) + diff) as usize;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Elements should not partially overlap each other; degrade gracefully by
|
||||
// snapping anything intersecting the replaced range to the new bounds.
|
||||
e.range.start = start.min(e.range.start);
|
||||
e.range.end = (start + inserted_len).max(e.range.end.saturating_add_signed(diff));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the cursor position to account for the edit.
|
||||
self.cursor_pos = if self.cursor_pos < start {
|
||||
self.cursor_pos
|
||||
} else if self.cursor_pos <= end {
|
||||
start + inserted_len
|
||||
} else {
|
||||
((self.cursor_pos as isize) + diff) as usize
|
||||
};
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
|
||||
// Keep element ordering deterministic.
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn insert_element(&mut self, text: &str) {
|
||||
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||||
self.insert_str_at(start, text);
|
||||
|
||||
@@ -1452,12 +1452,13 @@ impl ChatWidget {
|
||||
{
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
self.attach_image(
|
||||
path,
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label(),
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
@@ -1488,40 +1489,35 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// If a task is running, queue the user input to be sent after the turn completes.
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
_ => match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
InputResult::Queued(text) => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
tracing::info!(
|
||||
"attach_image path={path:?} width={width} height={height} format={format_label}",
|
||||
);
|
||||
self.bottom_pane
|
||||
.attach_image(path, width, height, format_label);
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1818,14 +1814,14 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(UserInput::Text { text: text.clone() });
|
||||
}
|
||||
|
||||
for path in image_paths {
|
||||
items.push(UserInput::LocalImage { path });
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(UserInput::Text { text: text.clone() });
|
||||
}
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
let skill_mentions = find_skill_mentions(&text, skills);
|
||||
for skill in skill_mentions {
|
||||
|
||||
@@ -1008,7 +1008,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "repeat me");
|
||||
|
||||
// Queue the prompt while the task is running.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
}
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 3);
|
||||
@@ -1030,7 +1030,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1065,8 +1065,8 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
||||
|
||||
chat.bottom_pane.insert_str("draft message ");
|
||||
chat.bottom_pane
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png");
|
||||
let placeholder = "[preview.png 24x42]";
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"));
|
||||
let placeholder = "[Image #1]";
|
||||
assert!(
|
||||
chat.bottom_pane.composer_text().ends_with(placeholder),
|
||||
"expected placeholder {placeholder:?} in composer text"
|
||||
|
||||
@@ -54,7 +54,9 @@ impl ComposerInput {
|
||||
/// Feed a key event into the composer and return a high-level action.
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted(text) => ComposerAction::Submitted(text),
|
||||
InputResult::Submitted(text) | InputResult::Queued(text) => {
|
||||
ComposerAction::Submitted(text)
|
||||
}
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
|
||||
@@ -17,6 +17,7 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
use super::format::FieldFormatter;
|
||||
@@ -62,6 +63,7 @@ struct StatusHistoryCell {
|
||||
approval: String,
|
||||
sandbox: String,
|
||||
agents_summary: String,
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
@@ -129,6 +131,7 @@ impl StatusHistoryCell {
|
||||
}
|
||||
};
|
||||
let agents_summary = compose_agents_summary(config);
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let default_usage = TokenUsage::default();
|
||||
@@ -157,6 +160,7 @@ impl StatusHistoryCell {
|
||||
approval,
|
||||
sandbox,
|
||||
agents_summary,
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
token_usage,
|
||||
@@ -338,6 +342,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
|
||||
if self.model_provider.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Model provider");
|
||||
}
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
}
|
||||
@@ -380,6 +387,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
let directory_value = format_directory_display(&self.directory, Some(value_width));
|
||||
|
||||
lines.push(formatter.line("Model", model_spans));
|
||||
if let Some(model_provider) = self.model_provider.as_ref() {
|
||||
lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())]));
|
||||
}
|
||||
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
|
||||
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
|
||||
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
|
||||
@@ -415,3 +425,39 @@ impl HistoryCell for StatusHistoryCell {
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config) -> Option<String> {
|
||||
let provider = &config.model_provider;
|
||||
let name = provider.name.trim();
|
||||
let provider_name = if name.is_empty() {
|
||||
config.model_provider_id.as_str()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let base_url = provider.base_url.as_deref().and_then(sanitize_base_url);
|
||||
let is_default_openai = provider.is_openai() && base_url.is_none();
|
||||
if is_default_openai {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match base_url {
|
||||
Some(base_url) => format!("{provider_name} - {base_url}"),
|
||||
None => provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# AGENTS.md
|
||||
|
||||
For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md).
|
||||
|
||||
## Hierarchical agents message
|
||||
|
||||
When the `hierarchical_agents` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present.
|
||||
|
||||
42
rbe.bzl
Normal file
42
rbe.bzl
Normal file
@@ -0,0 +1,42 @@
|
||||
def _rbe_platform_repo_impl(rctx):
|
||||
arch = rctx.os.arch
|
||||
if arch in ["x86_64", "amd64"]:
|
||||
cpu = "x86_64"
|
||||
exec_arch = "amd64"
|
||||
image_sha = "8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb"
|
||||
elif arch in ["aarch64", "arm64"]:
|
||||
cpu = "aarch64"
|
||||
exec_arch = "arm64"
|
||||
image_sha = "ad9506086215fccfc66ed8d2be87847324be56790ae6a1964c241c28b77ef141"
|
||||
else:
|
||||
fail("Unsupported host arch for rbe platform: {}".format(arch))
|
||||
|
||||
rctx.file("BUILD.bazel", """\
|
||||
platform(
|
||||
name = "rbe_platform",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:{cpu}",
|
||||
"@platforms//os:linux",
|
||||
"@bazel_tools//tools/cpp:clang",
|
||||
"@toolchains_llvm_bootstrapped//constraints/libc:gnu.2.28",
|
||||
],
|
||||
exec_properties = {{
|
||||
# Ubuntu-based image that includes git, python3, dotslash, and other
|
||||
# tools that various integration tests need.
|
||||
# Verify at https://hub.docker.com/layers/mbolin491/codex-bazel/latest/images/sha256:{image_sha}
|
||||
"container-image": "docker://docker.io/mbolin491/codex-bazel@sha256:{image_sha}",
|
||||
"Arch": "{arch}",
|
||||
"OSFamily": "Linux",
|
||||
}},
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
""".format(
|
||||
cpu = cpu,
|
||||
arch = exec_arch,
|
||||
image_sha = image_sha
|
||||
))
|
||||
|
||||
rbe_platform_repository = repository_rule(
|
||||
implementation = _rbe_platform_repo_impl,
|
||||
doc = "Sets up a platform for remote builds with an Arch exec_property matching the host.",
|
||||
)
|
||||
Reference in New Issue
Block a user