mirror of
https://github.com/openai/codex.git
synced 2026-02-02 23:13:37 +00:00
Compare commits
6 Commits
pr9034
...
queue/stee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dfd05b6c2 | ||
|
|
3c20ed8900 | ||
|
|
8ce2488dc2 | ||
|
|
1b26719958 | ||
|
|
6a57d7980b | ||
|
|
198289934f |
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 \
|
||||
|
||||
21
.github/workflows/bazel.yml
vendored
21
.github/workflows/bazel.yml
vendored
@@ -108,24 +108,3 @@ jobs:
|
||||
--build_metadata=ROLE=CI \
|
||||
--build_metadata=VISIBILITY=PUBLIC \
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY"
|
||||
|
||||
cloud-build:
|
||||
name: just bazel-remote-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Bazel
|
||||
uses: bazelbuild/setup-bazelisk@v3
|
||||
- name: bazel test //... --config=remote
|
||||
env:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bazel test //... \
|
||||
--build_metadata=REPO_URL=https://github.com/openai/codex.git \
|
||||
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD) \
|
||||
--build_metadata=ROLE=CI \
|
||||
--build_metadata=VISIBILITY=PUBLIC \
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \
|
||||
--config=remote --platforms=//:rbe --keep_going
|
||||
|
||||
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
|
||||
|
||||
|
||||
16
BUILD.bazel
16
BUILD.bazel
@@ -11,21 +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",
|
||||
)
|
||||
|
||||
17
announcement_tip.toml
Normal file
17
announcement_tip.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Example announcement tips for Codex TUI.
|
||||
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
|
||||
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
|
||||
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
|
||||
# target_app specify which app should display the announcement (cli, vsce, ...).
|
||||
|
||||
[[announcements]]
|
||||
content = "Welcome to Codex! Check out the new onboarding flow."
|
||||
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$"
|
||||
to_date = "2026-01-10"
|
||||
@@ -509,17 +509,12 @@ pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value {
|
||||
}
|
||||
|
||||
pub fn ev_shell_command_call(call_id: &str, command: &str) -> Value {
|
||||
let args = serde_json::json!({ "command": command, "login": false });
|
||||
let args = serde_json::json!({ "command": command });
|
||||
ev_shell_command_call_with_args(call_id, &args)
|
||||
}
|
||||
|
||||
pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value {
|
||||
let mut args = args.clone();
|
||||
if let serde_json::Value::Object(map) = &mut args {
|
||||
map.entry("login".to_string())
|
||||
.or_insert_with(|| serde_json::Value::Bool(false));
|
||||
}
|
||||
let arguments = serde_json::to_string(&args).expect("serialize shell command arguments");
|
||||
let arguments = serde_json::to_string(args).expect("serialize shell command arguments");
|
||||
ev_function_call(call_id, "shell_command", &arguments)
|
||||
}
|
||||
|
||||
@@ -532,18 +527,17 @@ pub fn ev_apply_patch_shell_call(call_id: &str, patch: &str) -> Value {
|
||||
|
||||
pub fn ev_apply_patch_shell_call_via_heredoc(call_id: &str, patch: &str) -> Value {
|
||||
let script = format!("apply_patch <<'EOF'\n{patch}\nEOF\n");
|
||||
let args = serde_json::json!({ "command": ["bash", "-c", script] });
|
||||
let args = serde_json::json!({ "command": ["bash", "-lc", script] });
|
||||
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
|
||||
|
||||
ev_function_call(call_id, "shell", &arguments)
|
||||
}
|
||||
|
||||
pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value {
|
||||
let args = serde_json::json!({
|
||||
"command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n"),
|
||||
"login": false,
|
||||
});
|
||||
ev_shell_command_call_with_args(call_id, &args)
|
||||
let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") });
|
||||
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
|
||||
|
||||
ev_function_call(call_id, "shell_command", &arguments)
|
||||
}
|
||||
|
||||
pub fn sse_failed(id: &str, code: &str, message: &str) -> String {
|
||||
|
||||
@@ -30,11 +30,9 @@ use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use tempfile::Builder;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -47,52 +45,6 @@ enum TargetPath {
|
||||
OutsideWorkspace(&'static str),
|
||||
}
|
||||
|
||||
fn candidate_is_outside_tmp(candidate: &Path) -> bool {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
if candidate.starts_with(&temp_dir) {
|
||||
return false;
|
||||
}
|
||||
if cfg!(unix) && candidate.starts_with(Path::new("/tmp")) {
|
||||
return false;
|
||||
}
|
||||
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
||||
let tmpdir = PathBuf::from(tmpdir);
|
||||
if candidate.starts_with(&tmpdir) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn outside_workspace_root() -> &'static PathBuf {
|
||||
static OUTSIDE_ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
OUTSIDE_ROOT.get_or_init(|| {
|
||||
let mut candidates = Vec::new();
|
||||
if cfg!(unix) {
|
||||
candidates.push(PathBuf::from("/var/tmp"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
candidates.push(PathBuf::from(home));
|
||||
}
|
||||
for candidate in candidates {
|
||||
if !candidate.is_dir() || !candidate_is_outside_tmp(&candidate) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(dir) = Builder::new()
|
||||
.prefix("codex-outside-")
|
||||
.tempdir_in(&candidate)
|
||||
{
|
||||
return dir.keep();
|
||||
}
|
||||
}
|
||||
Builder::new()
|
||||
.prefix("codex-outside-")
|
||||
.tempdir()
|
||||
.expect("create outside workspace temp dir")
|
||||
.keep()
|
||||
})
|
||||
}
|
||||
|
||||
impl TargetPath {
|
||||
fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) {
|
||||
match self {
|
||||
@@ -101,7 +53,9 @@ impl TargetPath {
|
||||
(path, name.to_string())
|
||||
}
|
||||
TargetPath::OutsideWorkspace(name) => {
|
||||
let path = outside_workspace_root().join(name);
|
||||
let path = env::current_dir()
|
||||
.expect("current dir should be available")
|
||||
.join(name);
|
||||
(path.clone(), path.display().to_string())
|
||||
}
|
||||
}
|
||||
@@ -233,7 +187,6 @@ fn shell_event(
|
||||
) -> Result<Value> {
|
||||
let mut args = json!({
|
||||
"command": command,
|
||||
"login": false,
|
||||
"timeout_ms": timeout_ms,
|
||||
});
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -49,7 +49,6 @@ fn shell_responses(
|
||||
let command = shlex::try_join(command)?;
|
||||
let parameters = json!({
|
||||
"command": command,
|
||||
"login": false,
|
||||
"timeout_ms": 2_000,
|
||||
});
|
||||
Ok(vec![
|
||||
|
||||
@@ -46,15 +46,6 @@ fn extract_output_text(item: &Value) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn exec_command_args(mut args: Value) -> Value {
|
||||
if let Value::Object(map) = &mut args
|
||||
&& map.contains_key("cmd")
|
||||
{
|
||||
map.entry("login".to_string()).or_insert(Value::Bool(false));
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedUnifiedExecOutput {
|
||||
chunk_id: Option<String>,
|
||||
@@ -180,10 +171,10 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
"*** Begin Patch\n*** Add File: uexec_apply.txt\n+hello from unified exec\n*** End Patch";
|
||||
let command = format!("apply_patch <<'EOF'\n{patch}\nEOF\n");
|
||||
let call_id = "uexec-apply-patch";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -308,11 +299,11 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-begin-event";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"shell": "bash".to_string(),
|
||||
"cmd": "/bin/echo hello unified exec".to_string(),
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -351,7 +342,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_command(&begin_event.command, "-c", "/bin/echo hello unified exec");
|
||||
assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec");
|
||||
|
||||
assert_eq!(begin_event.cwd, cwd.path());
|
||||
|
||||
@@ -383,11 +374,11 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
std::fs::create_dir_all(cwd.path().join(&workdir_rel))?;
|
||||
|
||||
let call_id = "uexec-workdir-relative";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": "pwd",
|
||||
"yield_time_ms": 250,
|
||||
"workdir": workdir_rel.to_string_lossy().to_string(),
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -461,11 +452,11 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
std::fs::create_dir_all(&workdir)?;
|
||||
|
||||
let call_id = "uexec-workdir";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": "pwd",
|
||||
"yield_time_ms": 250,
|
||||
"workdir": workdir.to_string_lossy().to_string(),
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -537,16 +528,16 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-end-event";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": "/bin/echo END-EVENT".to_string(),
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
let poll_call_id = "uexec-end-event-poll";
|
||||
let poll_args = exec_command_args(json!({
|
||||
let poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -624,10 +615,10 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-delta-1";
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": "printf 'HELLO-UEXEC'",
|
||||
"yield_time_ms": 1000,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -697,10 +688,10 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
|
||||
let call_id = "uexec-full-lifecycle";
|
||||
// This timing force the long-standing PTY
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": "sleep 0.5; printf 'HELLO-FULL-LIFECYCLE'",
|
||||
"yield_time_ms": 1000,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -803,17 +794,17 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let open_call_id = "uexec-open";
|
||||
let open_args = exec_command_args(json!({
|
||||
let open_args = json!({
|
||||
"cmd": "/bin/bash -i",
|
||||
"yield_time_ms": 200,
|
||||
}));
|
||||
});
|
||||
|
||||
let stdin_call_id = "uexec-stdin-delta";
|
||||
let stdin_args = exec_command_args(json!({
|
||||
let stdin_args = json!({
|
||||
"chars": "echo WSTDIN-MARK\\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 800,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -902,33 +893,33 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let open_call_id = "uexec-delayed-open";
|
||||
let open_args = exec_command_args(json!({
|
||||
let open_args = json!({
|
||||
"cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2",
|
||||
"yield_time_ms": 10,
|
||||
}));
|
||||
});
|
||||
|
||||
// Poll stdin three times: first for no output, second after the first marker,
|
||||
// and a final long poll to capture the second marker.
|
||||
let first_poll_call_id = "uexec-delayed-poll-1";
|
||||
let first_poll_args = exec_command_args(json!({
|
||||
let first_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 10,
|
||||
}));
|
||||
});
|
||||
|
||||
let second_poll_call_id = "uexec-delayed-poll-2";
|
||||
let second_poll_args = exec_command_args(json!({
|
||||
let second_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 4000,
|
||||
}));
|
||||
});
|
||||
|
||||
let third_poll_call_id = "uexec-delayed-poll-3";
|
||||
let third_poll_args = exec_command_args(json!({
|
||||
let third_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 6000,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1092,18 +1083,18 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let open_call_id = "uexec-open-session";
|
||||
let open_args = exec_command_args(json!({
|
||||
let open_args = json!({
|
||||
"shell": "bash".to_string(),
|
||||
"cmd": "sleep 0.1".to_string(),
|
||||
"yield_time_ms": 10,
|
||||
}));
|
||||
});
|
||||
|
||||
let poll_call_id = "uexec-poll-empty";
|
||||
let poll_args = exec_command_args(json!({
|
||||
let poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 150,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1175,7 +1166,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
|
||||
let open_event = &begin_events[0];
|
||||
|
||||
assert_command(&open_event.command, "-c", "sleep 0.1");
|
||||
assert_command(&open_event.command, "-lc", "sleep 0.1");
|
||||
|
||||
assert!(
|
||||
open_event.interaction_input.is_none(),
|
||||
@@ -1208,11 +1199,11 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-metadata";
|
||||
let args = exec_command_args(serde_json::json!({
|
||||
let args = serde_json::json!({
|
||||
"cmd": "printf 'token one token two token three token four token five token six token seven'",
|
||||
"yield_time_ms": 500,
|
||||
"max_output_tokens": 6,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1315,10 +1306,10 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-early-exit";
|
||||
let args = exec_command_args(serde_json::json!({
|
||||
let args = serde_json::json!({
|
||||
"cmd": "sleep 0.05",
|
||||
"yield_time_ms": 31415,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1410,20 +1401,20 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
let send_call_id = "uexec-cat-send";
|
||||
let exit_call_id = "uexec-cat-exit";
|
||||
|
||||
let start_args = exec_command_args(serde_json::json!({
|
||||
let start_args = serde_json::json!({
|
||||
"cmd": "/bin/cat",
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
let send_args = exec_command_args(serde_json::json!({
|
||||
});
|
||||
let send_args = serde_json::json!({
|
||||
"chars": "hello unified exec\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
let exit_args = exec_command_args(serde_json::json!({
|
||||
});
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "\u{0004}",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1569,24 +1560,24 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let start_call_id = "uexec-end-on-exit-start";
|
||||
let start_args = exec_command_args(serde_json::json!({
|
||||
let start_args = serde_json::json!({
|
||||
"cmd": "/bin/cat",
|
||||
"yield_time_ms": 200,
|
||||
}));
|
||||
});
|
||||
|
||||
let echo_call_id = "uexec-end-on-exit-echo";
|
||||
let echo_args = exec_command_args(serde_json::json!({
|
||||
let echo_args = serde_json::json!({
|
||||
"chars": "bye-END\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 300,
|
||||
}));
|
||||
});
|
||||
|
||||
let exit_call_id = "uexec-end-on-exit";
|
||||
let exit_args = exec_command_args(serde_json::json!({
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "\u{0004}",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1679,10 +1670,10 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
|
||||
|
||||
let call_id = "uexec-long-running";
|
||||
let command = format!("printf '%s' $$ > '{pid_path_str}' && exec sleep 3000");
|
||||
let args = exec_command_args(json!({
|
||||
let args = json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1778,17 +1769,17 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let first_call_id = "uexec-start";
|
||||
let first_args = exec_command_args(serde_json::json!({
|
||||
let first_args = serde_json::json!({
|
||||
"cmd": "/bin/cat",
|
||||
"yield_time_ms": 200,
|
||||
}));
|
||||
});
|
||||
|
||||
let second_call_id = "uexec-stdin";
|
||||
let second_args = exec_command_args(serde_json::json!({
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "hello unified exec\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -1909,17 +1900,17 @@ PY
|
||||
"#;
|
||||
|
||||
let first_call_id = "uexec-lag-start";
|
||||
let first_args = exec_command_args(serde_json::json!({
|
||||
let first_args = serde_json::json!({
|
||||
"cmd": script,
|
||||
"yield_time_ms": 25,
|
||||
}));
|
||||
});
|
||||
|
||||
let second_call_id = "uexec-lag-poll";
|
||||
let second_args = exec_command_args(serde_json::json!({
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 2_000,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2020,17 +2011,17 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let first_call_id = "uexec-timeout";
|
||||
let first_args = exec_command_args(serde_json::json!({
|
||||
let first_args = serde_json::json!({
|
||||
"cmd": "sleep 0.5; echo ready",
|
||||
"yield_time_ms": 10,
|
||||
}));
|
||||
});
|
||||
|
||||
let second_call_id = "uexec-poll";
|
||||
let second_args = exec_command_args(serde_json::json!({
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 800,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2132,11 +2123,11 @@ PY
|
||||
"#;
|
||||
|
||||
let call_id = "uexec-large-output";
|
||||
let args = exec_command_args(serde_json::json!({
|
||||
let args = serde_json::json!({
|
||||
"cmd": script,
|
||||
"max_output_tokens": 100,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2211,10 +2202,10 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec";
|
||||
let args = exec_command_args(serde_json::json!({
|
||||
let args = serde_json::json!({
|
||||
"cmd": "echo 'hello'",
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2291,17 +2282,17 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let startup_call_id = "uexec-python-seatbelt";
|
||||
let startup_args = exec_command_args(serde_json::json!({
|
||||
let startup_args = serde_json::json!({
|
||||
"cmd": format!("{} -i", python.display()),
|
||||
"yield_time_ms": 1_500,
|
||||
}));
|
||||
});
|
||||
|
||||
let exit_call_id = "uexec-python-exit";
|
||||
let exit_args = exec_command_args(serde_json::json!({
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "exit()\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 1_500,
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2405,9 +2396,9 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec";
|
||||
let args = exec_command_args(serde_json::json!({
|
||||
let args = serde_json::json!({
|
||||
"cmd": "echo 'hello crossplat'",
|
||||
}));
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
@@ -2481,17 +2472,17 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
const FILLER_SESSIONS: i32 = MAX_SESSIONS_FOR_TEST - 1;
|
||||
|
||||
let keep_call_id = "uexec-prune-keep";
|
||||
let keep_args = exec_command_args(serde_json::json!({
|
||||
let keep_args = serde_json::json!({
|
||||
"cmd": "/bin/cat",
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
|
||||
let prune_call_id = "uexec-prune-target";
|
||||
// Give the sleeper time to exit before the filler sessions trigger pruning.
|
||||
let prune_args = exec_command_args(serde_json::json!({
|
||||
let prune_args = serde_json::json!({
|
||||
"cmd": "sleep 1",
|
||||
"yield_time_ms": 1_250,
|
||||
}));
|
||||
});
|
||||
|
||||
let mut events = vec![ev_response_created("resp-prune-1")];
|
||||
events.push(ev_function_call(
|
||||
@@ -2506,10 +2497,10 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
));
|
||||
|
||||
for idx in 0..FILLER_SESSIONS {
|
||||
let filler_args = exec_command_args(serde_json::json!({
|
||||
let filler_args = serde_json::json!({
|
||||
"cmd": format!("echo filler {idx}"),
|
||||
"yield_time_ms": 250,
|
||||
}));
|
||||
});
|
||||
let call_id = format!("uexec-prune-fill-{idx}");
|
||||
events.push(ev_function_call(
|
||||
&call_id,
|
||||
@@ -2519,11 +2510,11 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
}
|
||||
|
||||
let keep_write_call_id = "uexec-prune-keep-write";
|
||||
let keep_write_args = exec_command_args(serde_json::json!({
|
||||
let keep_write_args = serde_json::json!({
|
||||
"chars": "still alive\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
events.push(ev_function_call(
|
||||
keep_write_call_id,
|
||||
"write_stdin",
|
||||
@@ -2531,11 +2522,11 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
));
|
||||
|
||||
let probe_call_id = "uexec-prune-probe";
|
||||
let probe_args = exec_command_args(serde_json::json!({
|
||||
let probe_args = serde_json::json!({
|
||||
"chars": "should fail\n",
|
||||
"session_id": 1001,
|
||||
"yield_time_ms": 500,
|
||||
}));
|
||||
});
|
||||
events.push(ev_function_call(
|
||||
probe_call_id,
|
||||
"write_stdin",
|
||||
|
||||
@@ -80,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,
|
||||
@@ -101,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>,
|
||||
@@ -1198,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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "
|
||||
@@ -1630,25 +1630,29 @@ 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 => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -83,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,
|
||||
@@ -104,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>,
|
||||
@@ -1132,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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1489,25 +1489,29 @@ 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 => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
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