Merge remote-tracking branch 'origin/main' into etraut/extract-goal-extension

# Conflicts:
#	codex-rs/ext/goal/tests/goal_extension_backend.rs
This commit is contained in:
Eric Traut
2026-05-27 07:55:38 -07:00
36 changed files with 934 additions and 204 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ Example with notification opt-out:
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
@@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"mentions_v2",
"plugins",
"remote_control",
"remote_plugin",
"tool_suggest",
"tool_call_mcp_elicitation",
];
@@ -340,6 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
BTreeMap::from([
("memories".to_string(), true),
("plugins".to_string(), true),
("remote_plugin".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
@@ -352,6 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
enablement: BTreeMap::from([
("memories".to_string(), true),
("plugins".to_string(), true),
("remote_plugin".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
@@ -381,6 +383,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
.and_then(|features| features.get("plugins")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("remote_plugin")),
Some(&json!(true))
);
assert_eq!(
config
.additional
@@ -474,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -
);
assert!(
error.message.contains(
"apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation"
"apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation"
),
"{}",
error.message
+2
View File
@@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap {
pub delete_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
pub delete_to_line_end: Option<KeybindingsSpec>,
/// Change from cursor to end of line and enter insert mode (`C`).
pub change_to_line_end: Option<KeybindingsSpec>,
/// Yank the entire line (`Y`).
pub yank_line: Option<KeybindingsSpec>,
/// Paste after cursor (`p`).
+10
View File
@@ -2834,6 +2834,7 @@
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"change_to_line_end": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
@@ -3500,6 +3501,7 @@
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"change_to_line_end": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
@@ -3765,6 +3767,14 @@
],
"description": "Cancel a pending operator and return to normal mode."
},
"change_to_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Change from cursor to end of line and enter insert mode (`C`)."
},
"delete_char": {
"allOf": [
{
+102
View File
@@ -1113,6 +1113,108 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let startup_turn_context = parent_thread.codex.session.new_default_turn().await;
parent_thread
.codex
.session
.record_conversation_items(
startup_turn_context.as_ref(),
&[ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "parent startup developer context".to_string(),
}],
phase: None,
}],
)
.await;
parent_thread
.inject_user_message_without_turn("current parent task".to_string())
.await;
let spawn_turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string();
parent_thread
.codex
.session
.record_conversation_items(
spawn_turn_context.as_ref(),
&[spawn_agent_call(&parent_spawn_call_id)],
)
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
..Default::default()
},
)
.await
.expect("bounded forked spawn should drop startup prefix")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.codex.session.clone_history().await;
assert!(
history_contains_text(history.raw_items(), "current parent task"),
"bounded fork should retain the requested recent parent turn"
);
assert!(
!history_contains_text(history.raw_items(), "parent startup developer context"),
"bounded fork should drop parent startup context even when fewer turns exist than requested"
);
assert!(
child_thread
.codex
.session
.reference_context_item()
.await
.is_none(),
"bounded forked child should still rebuild context after truncating the cached prefix"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
let harness = AgentControlHarness::new().await;
@@ -905,6 +905,7 @@ pub(crate) fn build_guardian_review_session_config(
.map(guardian_policy_prompt_with_config)
.unwrap_or_else(guardian_policy_prompt),
);
guardian_config.notify = None;
guardian_config.developer_instructions = None;
guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
guardian_config
+19
View File
@@ -2215,6 +2215,25 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() {
);
}
#[tokio::test]
async fn guardian_review_session_config_clears_legacy_notify() {
let mut parent_config = test_config().await;
parent_config.notify = Some(vec![
"/path/to/notify".to_string(),
"turn-ended".to_string(),
]);
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.notify, None);
}
#[tokio::test]
async fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config().await;
+4 -3
View File
@@ -12,7 +12,7 @@ pub(super) fn schedule_turn(session: &Arc<Session>) {
if session
.services
.extensions
.idle_turn_contributors()
.thread_lifecycle_contributors()
.is_empty()
{
return;
@@ -77,14 +77,15 @@ async fn has_active_or_pending_work(session: &Session) -> bool {
async fn next_idle_turn_input(session: &Session) -> Option<Vec<TurnInput>> {
let collaboration_mode = session.collaboration_mode().await;
for contributor in session.services.extensions.idle_turn_contributors() {
for contributor in session.services.extensions.thread_lifecycle_contributors() {
let Some(items) = contributor
.next_idle_turn(codex_extension_api::IdleTurnInput {
.on_thread_idle(codex_extension_api::ThreadIdleInput {
collaboration_mode: &collaboration_mode,
session_store: &session.services.session_extension_data,
thread_store: &session.services.thread_extension_data,
})
.await
.map(|request| request.items)
else {
continue;
};
+10 -6
View File
@@ -130,7 +130,8 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
/// Return a suffix of `items` that keeps the last `n_from_end` fork turns.
///
/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout.
/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn
/// boundary and still drops pre-turn startup context.
pub(crate) fn truncate_rollout_to_last_n_fork_turns(
items: &[RolloutItem],
n_from_end: usize,
@@ -140,11 +141,14 @@ pub(crate) fn truncate_rollout_to_last_n_fork_turns(
}
let fork_turn_positions = fork_turn_positions_in_rollout(items);
if fork_turn_positions.len() <= n_from_end {
return items.to_vec();
}
let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end];
let Some(keep_idx) = fork_turn_positions
.len()
.checked_sub(n_from_end)
.map(|position| fork_turn_positions[position])
.or_else(|| fork_turn_positions.first().copied())
else {
return Vec::new();
};
items[keep_idx..].to_vec()
}
@@ -29,6 +29,17 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn developer_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
}
}
fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem {
let communication = InterAgentCommunication::new(
AgentPath::root(),
@@ -197,6 +208,23 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() {
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() {
let rollout = vec![
RolloutItem::ResponseItem(developer_msg("startup developer context")),
RolloutItem::ResponseItem(user_msg("current task")),
RolloutItem::ResponseItem(assistant_msg("answer")),
];
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2);
let expected = rollout[1..].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
#[test]
fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() {
let rollout = vec![
@@ -0,0 +1,170 @@
#![cfg(not(target_os = "windows"))]
use anyhow::Result;
use codex_core::config::Constrained;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::fs_wait;
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_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let notify_dir = TempDir::new()?;
let notify_script = notify_dir.path().join("notify.sh");
fs::write(
&notify_script,
r#"#!/bin/bash
set -e
payload_path="$(dirname "${0}")/notify.jsonl"
printf '%s\n' "${@: -1}" >> "${payload_path}""#,
)?;
fs::set_permissions(&notify_script, fs::Permissions::from_mode(0o755))?;
let notify_file = notify_dir.path().join("notify.jsonl");
let notify_script_str = notify_script.to_str().unwrap().to_string();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.notify = Some(vec![notify_script_str]);
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let output_file = test.cwd.path().join("guardian-review-notify.txt");
let command = format!("printf guardian-approved > {}", output_file.display());
let tool_args = json!({
"cmd": command,
"yield_time_ms": 1_000_u64,
"sandbox_permissions": SandboxPermissions::RequireEscalated,
"justification": "Exercise Guardian approval routing.",
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parent-tool"),
ev_function_call(
"exec-call",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_completed("resp-parent-tool"),
]),
sse(vec![
ev_response_created("resp-guardian-review"),
ev_assistant_message(
"msg-guardian-review",
&json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The command writes a marker file in the workspace.",
})
.to_string(),
),
ev_completed("resp-guardian-review"),
]),
sse(vec![
ev_response_created("resp-parent-done"),
ev_assistant_message("msg-parent-done", "done"),
ev_completed("resp-parent-done"),
]),
],
)
.await;
test.codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "run a command that requires Guardian review".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
cwd: Some(test.cwd.path().to_path_buf()),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
sandbox_policy: Some(sandbox_policy),
..Default::default()
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let guardian_request = responses
.requests()
.into_iter()
.find(|request| request.body_contains_text("Exercise Guardian approval routing."))
.expect("expected Guardian review request");
assert!(guardian_request.body_contains_text(&command));
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).await?;
tokio::time::sleep(Duration::from_millis(100)).await;
let notify_payload_raw = tokio::fs::read_to_string(&notify_file).await?;
let payloads: Vec<Value> = notify_payload_raw
.lines()
.map(serde_json::from_str::<Value>)
.collect::<std::result::Result<_, _>>()?;
assert_eq!(
payloads.len(),
1,
"unexpected notify payloads: {payloads:?}"
);
assert_eq!(
payloads[0]["input-messages"],
json!(["run a command that requires Guardian review"])
);
assert_eq!(payloads[0]["last-assistant-message"], json!("done"));
assert!(
!notify_payload_raw.contains(
"The following is the Codex agent history whose request action you are assessing."
),
"Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}"
);
assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved");
Ok(())
}
+2
View File
@@ -50,6 +50,8 @@ mod deprecation_notice;
mod exec;
mod exec_policy;
mod fork_thread;
#[cfg(not(target_os = "windows"))]
mod guardian_review;
mod hierarchical_agents;
#[cfg(not(target_os = "windows"))]
mod hooks;
+11 -23
View File
@@ -1,12 +1,9 @@
//! Contributor traits and inputs that let extensions hook into host-owned workflows.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenUsageInfo;
@@ -22,6 +19,8 @@ mod turn_lifecycle;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadIdleInput;
pub use thread_lifecycle::ThreadIdleRequest;
pub use thread_lifecycle::ThreadResumeInput;
pub use thread_lifecycle::ThreadStartInput;
pub use thread_lifecycle::ThreadStopInput;
@@ -58,6 +57,15 @@ pub trait ThreadLifecycleContributor<C: Sync>: Send + Sync {
/// Called after the host constructs a runtime from persisted history.
async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {}
/// Called after the host has drained immediately pending thread work.
///
/// Implementations may use host capabilities captured by the extension to
/// submit follow-up input. The host remains responsible for deciding
/// whether that input starts a turn, is queued, or is ignored.
async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) -> Option<ThreadIdleRequest> {
None
}
/// Called before the host drops the thread runtime and thread-scoped store.
async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {}
}
@@ -149,26 +157,6 @@ pub struct ToolContributionInput<'a> {
pub persistent_thread: bool,
}
/// Context supplied when the host is idle and extensions may request work.
pub struct IdleTurnInput<'a> {
/// Effective collaboration mode for the next default turn.
pub collaboration_mode: &'a CollaborationMode,
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
}
pub type IdleTurnFuture<'a> =
Pin<Box<dyn Future<Output = Option<Vec<ResponseInputItem>>> + Send + 'a>>;
/// Extension contribution that can request host-owned work while a thread is idle.
pub trait IdleTurnContributor: Send + Sync {
fn next_idle_turn<'a>(&'a self, _input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> {
Box::pin(std::future::ready(None))
}
}
/// Contributor for host-owned tool lifecycle gates.
///
/// Implementations should use these callbacks to observe tool execution without
@@ -1,3 +1,6 @@
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::models::ResponseInputItem;
use crate::ExtensionData;
/// Input supplied when the host starts a runtime for a thread.
@@ -18,6 +21,21 @@ pub struct ThreadResumeInput<'a> {
pub thread_store: &'a ExtensionData,
}
/// Input supplied when the host has no immediately pending thread work.
pub struct ThreadIdleInput<'a> {
/// Effective collaboration mode for the next default turn.
pub collaboration_mode: &'a CollaborationMode,
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
}
/// Extension request to start a new idle turn.
pub struct ThreadIdleRequest {
pub items: Vec<ResponseInputItem>,
}
/// Input supplied when the host stops a thread runtime.
pub struct ThreadStopInput<'a> {
/// Store scoped to the host session runtime.
+2 -3
View File
@@ -25,11 +25,10 @@ pub use codex_tools::parse_tool_input_schema_without_compaction;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ConfigContributor;
pub use contributors::ContextContributor;
pub use contributors::IdleTurnContributor;
pub use contributors::IdleTurnFuture;
pub use contributors::IdleTurnInput;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ThreadIdleInput;
pub use contributors::ThreadIdleRequest;
pub use contributors::ThreadLifecycleContributor;
pub use contributors::ThreadResumeInput;
pub use contributors::ThreadStartInput;
@@ -7,7 +7,6 @@ use crate::ConfigContributor;
use crate::ContextContributor;
use crate::ExtensionData;
use crate::ExtensionEventSink;
use crate::IdleTurnContributor;
use crate::NoopExtensionEventSink;
use crate::ThreadLifecycleContributor;
use crate::TokenUsageContributor;
@@ -27,7 +26,6 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
idle_turn_contributors: Vec<Arc<dyn IdleTurnContributor>>,
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
}
@@ -44,7 +42,6 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
tool_contributors: Vec::new(),
tool_lifecycle_contributors: Vec::new(),
turn_item_contributors: Vec::new(),
idle_turn_contributors: Vec::new(),
}
}
}
@@ -116,11 +113,6 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
self.turn_item_contributors.push(contributor);
}
/// Registers one idle-turn contributor.
pub fn idle_turn_contributor(&mut self, contributor: Arc<dyn IdleTurnContributor>) {
self.idle_turn_contributors.push(contributor);
}
/// Finishes construction and returns the immutable registry.
pub fn build(self) -> ExtensionRegistry<C> {
ExtensionRegistry {
@@ -134,7 +126,6 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
tool_contributors: self.tool_contributors,
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
turn_item_contributors: self.turn_item_contributors,
idle_turn_contributors: self.idle_turn_contributors,
}
}
}
@@ -150,7 +141,6 @@ pub struct ExtensionRegistry<C: Sync> {
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
idle_turn_contributors: Vec<Arc<dyn IdleTurnContributor>>,
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
}
@@ -219,11 +209,6 @@ impl<C: Sync> ExtensionRegistry<C> {
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor>] {
&self.turn_item_contributors
}
/// Returns the registered idle-turn contributors.
pub fn idle_turn_contributors(&self) -> &[Arc<dyn IdleTurnContributor>] {
&self.idle_turn_contributors
}
}
/// Creates an empty shared registry for hosts that do not register contributions.
+11
View File
@@ -83,6 +83,17 @@ impl GoalAccountingState {
self.inner().current_turn_id.clone()
}
pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool {
let inner = self.inner();
if inner.current_turn_id.as_deref() != Some(turn_id) {
return false;
}
let Some(turn) = inner.turns.get(turn_id) else {
return false;
};
turn.account_tokens && turn.active_goal_id.is_some()
}
pub(crate) fn record_token_usage(
&self,
turn_id: impl Into<String>,
+18 -27
View File
@@ -7,9 +7,8 @@ use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::IdleTurnContributor;
use codex_extension_api::IdleTurnFuture;
use codex_extension_api::IdleTurnInput;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadIdleRequest;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadResumeInput;
use codex_extension_api::ThreadStartInput;
@@ -128,6 +127,21 @@ where
);
}
}
async fn on_thread_idle(&self, input: ThreadIdleInput<'_>) -> Option<ThreadIdleRequest> {
let runtime = goal_runtime_handle(input.thread_store)?;
match runtime
.idle_continuation_items(input.collaboration_mode.mode)
.await
{
Ok(Some(items)) => Some(ThreadIdleRequest { items }),
Ok(None) => None,
Err(err) => {
tracing::warn!("failed to request idle goal continuation: {err}");
None
}
}
}
}
impl<C> ConfigContributor<C> for GoalExtension<C>
@@ -417,28 +431,6 @@ where
}
}
impl<C> IdleTurnContributor for GoalExtension<C>
where
C: Send + Sync + 'static,
{
fn next_idle_turn<'a>(&'a self, input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> {
Box::pin(async move {
let runtime = goal_runtime_handle(input.thread_store)?;
match runtime
.idle_continuation_items(input.collaboration_mode.mode)
.await
{
Ok(Some(items)) => Some(items),
Ok(None) => None,
Err(err) => {
tracing::warn!("failed to request idle goal continuation: {err}");
None
}
}
})
}
}
pub fn install_with_backend<C>(
registry: &mut ExtensionRegistryBuilder<C>,
state_dbs: Arc<codex_state::StateRuntime>,
@@ -460,8 +452,7 @@ pub fn install_with_backend<C>(
registry.turn_lifecycle_contributor(extension.clone());
registry.token_usage_contributor(extension.clone());
registry.tool_lifecycle_contributor(extension.clone());
registry.tool_contributor(extension.clone());
registry.idle_turn_contributor(extension);
registry.tool_contributor(extension);
}
fn goal_runtime_handle(thread_store: &ExtensionData) -> Option<Arc<GoalRuntimeHandle>> {
+48 -39
View File
@@ -177,6 +177,54 @@ impl GoalRuntimeHandle {
Ok(())
}
pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
}
if !self
.inner
.accounting_state
.turn_is_current_active_goal(turn_id)
{
return Ok(());
}
let progress_event_id = format!("{turn_id}:usage-limit-progress");
self.account_active_goal_progress(
turn_id,
progress_event_id.as_str(),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await?;
let previous_status = self
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
.await?;
let Some(goal) = self
.inner
.state_dbs
.thread_goals()
.usage_limit_active_thread_goal(self.thread_id())
.await
.map_err(|err| err.to_string())?
else {
return Ok(());
};
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner.accounting_state.clear_active_goal();
let goal = protocol_goal_from_state(goal);
self.inner.event_emitter.thread_goal_updated(
format!("{turn_id}:usage-limit"),
Some(turn_id.to_string()),
goal,
);
Ok(())
}
pub async fn restore_after_resume(&self) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
@@ -201,45 +249,6 @@ impl GoalRuntimeHandle {
Ok(())
}
pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
}
self.account_active_goal_progress(
turn_id,
&format!("{turn_id}:usage-limit"),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await?;
let previous_status = self
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
.await?;
let Some(goal) = self
.inner
.state_dbs
.thread_goals()
.usage_limit_active_thread_goal(self.thread_id())
.await
.map_err(|err| err.to_string())?
else {
self.inner.accounting_state.finish_turn(turn_id);
return Ok(());
};
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner.accounting_state.finish_turn(turn_id);
let goal = protocol_goal_from_state(goal);
self.inner.event_emitter.thread_goal_updated(
format!("{turn_id}:usage-limit"),
Some(turn_id.to_string()),
goal,
);
Ok(())
}
pub async fn idle_continuation_items(
&self,
collaboration_mode: ModeKind,
@@ -8,7 +8,7 @@ use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::FunctionCallError;
use codex_extension_api::IdleTurnInput;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadResumeInput;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
@@ -409,7 +409,7 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re
assert_eq!(
vec![
CapturedGoalEvent {
event_id: "turn-1:usage-limit".to_string(),
event_id: "turn-1:usage-limit-progress".to_string(),
turn_id: Some("turn-1".to_string()),
status: ThreadGoalStatus::Active,
tokens_used: 23,
@@ -423,11 +423,34 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re
],
harness.sink.goal_events()
);
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 50, /*cached_input_tokens*/ 5,
/*output_tokens*/ 20, /*reasoning_output_tokens*/ 0,
/*total_tokens*/ 70,
),
)
.await;
harness
.notify_tool_finish("turn-1", "call-shell-after-usage-limit", "shell")
.await;
harness.stop_turn("turn-1").await;
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(23, goal.tokens_used);
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
Ok(())
}
#[tokio::test]
async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Result<()> {
async fn thread_idle_hook_requests_hidden_goal_continuation() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
@@ -462,6 +485,159 @@ async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Re
Ok(())
}
#[tokio::test]
async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
harness.start_turn("turn-1", &TokenUsage::default()).await;
let tools = harness.tools();
let create_tool = tool_by_name(&tools, "create_goal");
create_tool
.handle(tool_call(
"create_goal",
"call-create-goal",
json!({
"objective": "ship goal extension backend",
"token_budget": 25,
}),
))
.await?;
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 20, /*cached_input_tokens*/ 5,
/*output_tokens*/ 10, /*reasoning_output_tokens*/ 0,
/*total_tokens*/ 30,
),
)
.await;
harness
.notify_tool_finish("turn-1", "call-shell", "shell")
.await;
harness.sink.clear();
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 24, /*cached_input_tokens*/ 5,
/*output_tokens*/ 16, /*reasoning_output_tokens*/ 0,
/*total_tokens*/ 40,
),
)
.await;
harness
.runtime_handle()
.usage_limit_active_goal_for_turn("turn-1")
.await
.map_err(anyhow::Error::msg)?;
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(35, goal.tokens_used);
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
assert_eq!(
vec![
CapturedGoalEvent {
event_id: "turn-1:usage-limit-progress".to_string(),
turn_id: Some("turn-1".to_string()),
status: ThreadGoalStatus::BudgetLimited,
tokens_used: 35,
},
CapturedGoalEvent {
event_id: "turn-1:usage-limit".to_string(),
turn_id: Some("turn-1".to_string()),
status: ThreadGoalStatus::UsageLimited,
tokens_used: 35,
},
],
harness.sink.goal_events()
);
Ok(())
}
#[tokio::test]
async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
let tools = harness.tools();
let create_tool = tool_by_name(&tools, "create_goal");
create_tool
.handle(tool_call(
"create_goal",
"call-create-goal",
json!({ "objective": "ship goal extension backend" }),
))
.await?;
harness
.start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default())
.await;
harness.sink.clear();
harness
.runtime_handle()
.usage_limit_active_goal_for_turn("turn-plan")
.await
.map_err(anyhow::Error::msg)?;
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
assert_eq!(Vec::<CapturedGoalEvent>::new(), harness.sink.goal_events());
Ok(())
}
#[tokio::test]
async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
harness.start_turn("turn-1", &TokenUsage::default()).await;
let tools = harness.tools();
let create_tool = tool_by_name(&tools, "create_goal");
create_tool
.handle(tool_call(
"create_goal",
"call-create-goal",
json!({ "objective": "ship goal extension backend" }),
))
.await?;
harness.stop_turn("turn-1").await;
harness.start_turn("turn-2", &TokenUsage::default()).await;
harness.sink.clear();
harness
.runtime_handle()
.usage_limit_active_goal_for_turn("turn-1")
.await
.map_err(anyhow::Error::msg)?;
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
assert_eq!(Vec::<CapturedGoalEvent>::new(), harness.sink.goal_events());
Ok(())
}
#[tokio::test]
async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
@@ -815,8 +991,14 @@ impl GoalExtensionHarness {
}
async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) {
self.start_turn_with_mode(turn_id, ModeKind::Default, usage)
.await;
}
async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) {
let turn_store = ExtensionData::new(turn_id);
let collaboration_mode = default_collaboration_mode();
let mut collaboration_mode = default_collaboration_mode();
collaboration_mode.mode = mode;
for contributor in self.registry.turn_lifecycle_contributors() {
contributor
.on_turn_start(TurnStartInput {
@@ -911,16 +1093,16 @@ impl GoalExtensionHarness {
async fn next_idle_turn(&self, mode: ModeKind) -> Option<Vec<ResponseInputItem>> {
let mut collaboration_mode = default_collaboration_mode();
collaboration_mode.mode = mode;
for contributor in self.registry.idle_turn_contributors() {
for contributor in self.registry.thread_lifecycle_contributors() {
if let Some(request) = contributor
.next_idle_turn(IdleTurnInput {
.on_thread_idle(ThreadIdleInput {
collaboration_mode: &collaboration_mode,
session_store: &self.session_store,
thread_store: &self.thread_store,
})
.await
{
return Some(request);
return Some(request.items);
}
}
None
@@ -7,11 +7,10 @@ use super::auth::BedrockAuthMethod;
use super::auth::resolve_auth_method;
const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle";
const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [
const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [
"us-east-2",
"us-east-1",
"us-west-2",
"us-gov-west-1",
"ap-southeast-3",
"ap-south-1",
"ap-northeast-1",
@@ -73,10 +72,6 @@ mod tests {
base_url("ap-northeast-1").expect("supported region"),
"https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1"
);
assert_eq!(
base_url("us-gov-west-1").expect("supported region"),
"https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1"
);
}
#[test]
+3 -3
View File
@@ -400,16 +400,16 @@ impl App {
}
pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) {
let config = self.config.clone();
let cwd = self.config.cwd.to_path_buf();
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
if !config.features.enabled(Feature::Plugins) {
if !self.config.features.enabled(Feature::Plugins) {
app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None });
return;
}
tokio::spawn(async move {
match fetch_plugin_mentions(request_handle, config.cwd.to_path_buf()).await {
match fetch_plugin_mentions(request_handle, cwd).await {
Ok(plugins) => {
app_event_tx.send(AppEvent::PluginMentionsLoaded {
plugins: Some(plugins),
+10 -50
View File
@@ -506,9 +506,6 @@ impl App {
self.chat_widget
.on_marketplace_add_loaded(cwd.clone(), source, result);
if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace add");
}
self.fetch_plugins_list(app_server, cwd);
}
}
@@ -516,14 +513,7 @@ impl App {
let marketplace_contents_changed =
matches!(&result, Ok(response) if !response.upgraded_roots.is_empty());
if marketplace_contents_changed {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after marketplace upgrade"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.refresh_plugin_mentions_after_config_write();
}
self.chat_widget
.on_marketplace_upgrade_loaded(cwd.clone(), result);
@@ -558,11 +548,7 @@ impl App {
);
if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
{
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace remove");
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.refresh_plugin_mentions_after_config_write();
self.fetch_plugins_list(app_server, cwd);
}
}
@@ -609,11 +595,7 @@ impl App {
} => {
let install_succeeded = result.is_ok();
if install_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after plugin install");
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.refresh_plugin_mentions_after_config_write();
}
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
cwd.clone(),
@@ -665,14 +647,7 @@ impl App {
self.pending_plugin_enabled_writes.remove(&plugin_id);
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.refresh_plugin_mentions_after_config_write();
}
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
@@ -1303,14 +1278,7 @@ impl App {
} => {
let uninstall_succeeded = result.is_ok();
if uninstall_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin uninstall"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.refresh_plugin_mentions_after_config_write();
}
self.chat_widget.on_plugin_uninstall_loaded(
cwd.clone(),
@@ -1705,14 +1673,6 @@ impl App {
{
Ok(()) => {
self.chat_widget.update_skill_enabled(path, enabled);
if !app_server.uses_remote_workspace()
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
{
tracing::warn!(
error = %err,
"failed to refresh config after skill toggle"
);
}
}
Err(err) => {
let path_display = path.display();
@@ -1749,11 +1709,6 @@ impl App {
{
Ok(_) => {
self.chat_widget.update_connector_enabled(&id, enabled);
if !app_server.uses_remote_workspace()
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
{
tracing::warn!(error = %err, "failed to refresh config after app toggle");
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
@@ -2098,6 +2053,11 @@ impl App {
}
}
fn refresh_plugin_mentions_after_config_write(&mut self) {
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
async fn apply_keymap_clear(&mut self, context: String, action: String) {
let keymap_config = match crate::keymap_setup::keymap_without_custom_binding(
&self.config.tui_keymap,
-1
View File
@@ -684,7 +684,6 @@ impl App {
}
AppCommand::ReloadUserConfig => {
app_server.reload_user_config().await?;
self.refresh_in_memory_config_from_disk().await?;
Ok(true)
}
AppCommand::OverrideTurnContext { .. } => {
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "states.join(\"\\n\\n\")"
---
alpha beta gamma
^
alpha beta gamma
^
alpha beta gamma
^
+147 -7
View File
@@ -755,7 +755,12 @@ impl TextArea {
return;
}
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
self.kill_to_end_of_line();
self.vim_kill_to_end_of_line();
return;
}
if self.vim_normal_keymap.change_to_line_end.is_pressed(event) {
self.vim_kill_to_end_of_line();
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.yank_line.is_pressed(event) {
@@ -914,7 +919,7 @@ impl TextArea {
VimMotion::Down => self.move_cursor_down(),
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()),
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
}
@@ -1007,6 +1012,13 @@ impl TextArea {
}
}
fn vim_kill_to_end_of_line(&mut self) {
let eol = self.end_of_current_line();
if self.cursor_pos < eol {
self.kill_range(self.cursor_pos..eol);
}
}
pub fn kill_to_beginning_of_line(&mut self) {
let bol = self.beginning_of_current_line();
let range = if self.cursor_pos == bol {
@@ -1734,7 +1746,11 @@ impl TextArea {
}
pub(crate) fn end_of_next_word(&self) -> usize {
let suffix = &self.text[self.cursor_pos..];
self.end_of_next_word_from(self.cursor_pos)
}
fn end_of_next_word_from(&self, cursor_pos: usize) -> usize {
let suffix = &self.text[cursor_pos..];
let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
return self.text.len();
};
@@ -1742,16 +1758,16 @@ impl TextArea {
let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
let mut pieces = split_word_pieces(run).into_iter().peekable();
let Some((start, piece)) = pieces.next() else {
return self.cursor_pos + first_non_ws;
return cursor_pos + first_non_ws;
};
let word_start = self.cursor_pos + first_non_ws + start;
let word_start = cursor_pos + first_non_ws + start;
let mut end = word_start + piece.len();
if piece.chars().all(is_word_separator) {
while let Some((idx, piece)) = pieces.peek() {
if !piece.chars().all(is_word_separator) {
break;
}
end = self.cursor_pos + first_non_ws + *idx + piece.len();
end = cursor_pos + first_non_ws + *idx + piece.len();
pieces.next();
}
}
@@ -1759,8 +1775,22 @@ impl TextArea {
self.adjust_pos_out_of_elements(end, /*prefer_start*/ false)
}
fn vim_word_end_cursor(&self) -> usize {
fn vim_word_end_exclusive(&self) -> usize {
let end = self.end_of_next_word();
let target = if end > self.cursor_pos {
self.prev_atomic_boundary(end)
} else {
end
};
if target == self.cursor_pos && end < self.text.len() {
self.end_of_next_word_from(end)
} else {
end
}
}
fn vim_word_end_cursor(&self) -> usize {
let end = self.vim_word_end_exclusive();
if end > self.cursor_pos {
self.prev_atomic_boundary(end)
} else {
@@ -2275,6 +2305,60 @@ mod tests {
assert_eq!(t.cursor(), 11);
}
#[test]
fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() {
let mut t = ta_with("hello world\nnext line");
t.set_cursor(/*pos*/ 6);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT));
assert_eq!(t.text(), "hello \nnext line");
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), 6);
assert_eq!(t.kill_buffer, "world");
}
#[test]
fn vim_uppercase_c_changes_to_line_end() {
let mut t = ta_with("hello world\nnext line");
t.set_cursor(/*pos*/ 6);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello \nnext line");
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), 6);
}
#[test]
fn vim_d_at_line_end_does_not_remove_newline() {
let mut t = ta_with("hello\nworld");
t.set_cursor(/*pos*/ "hello".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello\nworld");
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert_eq!(t.kill_buffer, "");
}
#[test]
fn vim_c_at_line_end_enters_insert_without_removing_newline() {
let mut t = ta_with("hello\nworld");
t.set_cursor(/*pos*/ "hello".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello\nworld");
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), "hello".len());
assert_eq!(t.kill_buffer, "");
}
#[test]
fn vim_shift_o_opens_line_above_with_shift_only_binding() {
let mut t = ta_with("hello\nworld");
@@ -2348,6 +2432,62 @@ mod tests {
assert_eq!(t.kill_buffer, "c");
}
#[test]
fn vim_e_advances_from_each_word_end() {
let mut t = ta_with("alpha beta gamma");
t.set_cursor("alph".len()); // codespell:ignore alph
t.set_vim_enabled(/*enabled*/ true);
let mut states = Vec::new();
for _ in 0..3 {
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor())));
}
insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n"));
}
#[test]
fn vim_delete_to_word_end_advances_from_existing_word_end() {
let mut t = ta_with("alpha beta gamma");
t.set_cursor("alph".len()); // codespell:ignore alph
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph
assert_eq!(t.kill_buffer, "a beta");
}
#[test]
fn vim_e_from_word_end_can_land_on_trailing_space() {
let mut t = ta_with("alpha ");
t.set_cursor("alph".len()); // codespell:ignore alph
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert_eq!(t.cursor(), "alpha ".len());
}
#[test]
fn vim_e_advances_across_atomic_element_word_ends() {
let mut t = TextArea::new();
t.insert_str("alpha ");
t.insert_element("<element>");
t.insert_str(" gamma");
let element_start = t.elements[0].range.start;
t.set_cursor("alph".len()); // codespell:ignore alph
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert_eq!(t.cursor(), element_start);
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert_eq!(t.cursor(), "alpha <element> gamm".len());
}
#[test]
fn vim_dollar_lands_on_line_end_character() {
let mut t = ta_with("abc\n123");
+10
View File
@@ -156,6 +156,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) move_line_end: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) paste_after: Vec<KeyBinding>,
pub(crate) start_delete_operator: Vec<KeyBinding>,
@@ -469,6 +470,7 @@ impl RuntimeKeymap {
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
start_delete_operator: resolve_local!(
@@ -786,6 +788,10 @@ impl RuntimeKeymap {
shift(KeyCode::Char('d')),
plain(KeyCode::Char('D'))
],
change_to_line_end: default_bindings![
shift(KeyCode::Char('c')),
plain(KeyCode::Char('C'))
],
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
paste_after: default_bindings![plain(KeyCode::Char('p'))],
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
@@ -1176,6 +1182,10 @@ impl RuntimeKeymap {
"delete_to_line_end",
self.vim_normal.delete_to_line_end.as_slice(),
),
(
"change_to_line_end",
self.vim_normal.change_to_line_end.as_slice(),
),
("yank_line", self.vim_normal.yank_line.as_slice()),
("paste_after", self.vim_normal.paste_after.as_slice()),
(
+3
View File
@@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
@@ -263,6 +264,7 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after),
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
@@ -373,6 +375,7 @@ pub(super) fn bindings_for_action<'a>(
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
93 actions, 1 customized, 2 unbound.
94 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
94 actions, 0 customized, 3 unbound.
95 actions, 0 customized, 3 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (93 selectable)
tab: All (94 selectable)
tab: Common (19 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (2) (2 selectable)
tab: App (9 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (34 selectable)
tab: Vim (35 selectable)
tab: Navigation (20 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
93 actions, 0 customized, 2 unbound.
94 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
93 actions, 0 customized, 2 unbound.
94 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
+55 -4
View File
@@ -1,11 +1,18 @@
[CmdletBinding()]
param(
[string]$Release = "latest"
[string]$Release = $env:CODEX_RELEASE
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
if ([string]::IsNullOrWhiteSpace($Release)) {
$Release = "latest"
}
$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$"
function Write-Step {
param(
[string]$Message
@@ -27,6 +34,10 @@ function Prompt-YesNo {
[string]$Prompt
)
if ($NonInteractive) {
return $false
}
if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) {
return $false
}
@@ -55,6 +66,16 @@ function Normalize-Version {
return $RawVersion
}
function Assert-ValidReleaseVersion {
param(
[string]$Version
)
if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") {
throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]."
}
}
function Find-ReleaseAssetMetadata {
param(
[string]$AssetName,
@@ -141,6 +162,22 @@ function Path-Contains {
return $false
}
function Prepend-PathEntry {
param(
[string]$PathValue,
[string]$Entry
)
$needle = $Entry.TrimEnd("\")
$segments = @($Entry)
if (-not [string]::IsNullOrWhiteSpace($PathValue)) {
$segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) |
Where-Object { $_.TrimEnd("\") -ine $needle }
}
return ($segments -join ";")
}
function Invoke-WithInstallLock {
param(
[string]$LockPath,
@@ -181,6 +218,7 @@ function Remove-StaleInstallArtifacts {
function Resolve-Version {
$normalizedVersion = Normalize-Version -RawVersion $Release
Assert-ValidReleaseVersion -Version $normalizedVersion
if ($normalizedVersion -ne "latest") {
return $normalizedVersion
}
@@ -191,7 +229,9 @@ function Resolve-Version {
exit 1
}
return (Normalize-Version -RawVersion $release.tag_name)
$resolvedVersion = Normalize-Version -RawVersion $release.tag_name
Assert-ValidReleaseVersion -Version $resolvedVersion
return $resolvedVersion
}
function Get-VersionFromBinary {
@@ -839,7 +879,16 @@ try {
Maybe-HandleConflictingInstall -Conflict $conflictingInstall
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
$prioritizeVisibleBin = $null -ne $conflictingInstall
if ($prioritizeVisibleBin) {
$newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir
if ($newUserPath -cne $userPath) {
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
Write-Step "PATH updated for future PowerShell sessions."
} else {
Write-Step "$visibleBinDir is already first on PATH."
}
} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
if ([string]::IsNullOrWhiteSpace($userPath)) {
$newUserPath = $visibleBinDir
} else {
@@ -854,7 +903,9 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
Write-Step "PATH is already configured for future PowerShell sessions."
}
if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
if ($prioritizeVisibleBin) {
$env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir
} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
if ([string]::IsNullOrWhiteSpace($env:Path)) {
$env:Path = $visibleBinDir
} else {
+30 -2
View File
@@ -2,7 +2,8 @@
set -eu
RELEASE="latest"
RELEASE="${CODEX_RELEASE:-latest}"
NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}"
BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"
BIN_PATH="$BIN_DIR/codex"
@@ -46,6 +47,19 @@ normalize_version() {
esac
}
validate_version() {
version="$1"
if [ "$version" = "latest" ]; then
return
fi
if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then
echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2
exit 1
fi
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -60,6 +74,10 @@ parse_args() {
--help | -h)
cat <<EOF
Usage: install.sh [--release VERSION]
Environment:
CODEX_RELEASE Version to install; overridden by --release.
CODEX_NON_INTERACTIVE Set to 1, true, or yes to skip prompts.
EOF
exit 0
;;
@@ -259,6 +277,7 @@ require_command() {
resolve_version() {
normalized_version="$(normalize_version "$RELEASE")"
validate_version "$normalized_version"
if [ "$normalized_version" != "latest" ]; then
printf '%s\n' "$normalized_version"
@@ -273,6 +292,7 @@ resolve_version() {
exit 1
fi
validate_version "$resolved"
printf '%s\n' "$resolved"
}
@@ -304,7 +324,9 @@ add_to_path() {
case ":$PATH:" in
*":$BIN_DIR:"*)
return
if [ -z "$conflict_manager" ]; then
return
fi
;;
esac
@@ -544,6 +566,12 @@ classify_existing_codex() {
prompt_yes_no() {
prompt="$1"
case "$NON_INTERACTIVE" in
1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss])
return 1
;;
esac
if ( : </dev/tty ) 2>/dev/null; then
printf '%s [y/N] ' "$prompt" >/dev/tty
if ! IFS= read -r answer </dev/tty; then