diff --git a/crates/engine/src/registry.rs b/crates/engine/src/registry.rs index bb0a07710..3ee895e81 100644 --- a/crates/engine/src/registry.rs +++ b/crates/engine/src/registry.rs @@ -640,16 +640,15 @@ pub fn default_registry() -> HarnessRegistry { Box::new(|| zeron_harness::CursorHarness::new().installed()), Box::new(|| Ok(Arc::new(zeron_harness::CursorHarness::new()) as Arc)), ); - // Devin over ACP (`devin acp`), same lazy pattern: the static descriptor - // mirrors AcpHarness::devin() exactly. No steering extension (turn - // boundaries) and no effort ladder — Devin bakes effort into the - // advertised model ids instead of a `thought_level` option. + // Devin over ACP: the lazy descriptor matches AcpHarness::devin(). + // Steering waits for running tools, then continues the turn. + // Devin has no effort ladder. Its advertised model IDs carry the effort choice. registry.register_lazy( HarnessDescriptor { id: HarnessId::Devin, name: "Devin".into(), supports_steering: true, - steering_mode: SteeringMode::TurnBoundary, + steering_mode: SteeringMode::StepBoundary, reasoning_levels: Vec::new(), installed: true, can_install: false, @@ -658,16 +657,15 @@ pub fn default_registry() -> HarnessRegistry { Box::new(|| zeron_harness::AcpHarness::devin().installed()), Box::new(|| Ok(Arc::new(zeron_harness::AcpHarness::devin()) as Arc)), ); - // Grok Build over ACP, same lazy pattern: the static descriptor mirrors - // AcpHarness::grok() exactly. No `_session/steering` extension yet, so - // steers deliver at turn boundaries; the effort ladder applies per - // session via the `thought_level` config option. + // Grok over ACP: the lazy descriptor matches AcpHarness::grok(). + // Steering waits for running tools, then continues the turn. + // The thought_level session option carries the effort choice. registry.register_lazy( HarnessDescriptor { id: HarnessId::Grok, name: "Grok".into(), supports_steering: true, - steering_mode: SteeringMode::TurnBoundary, + steering_mode: SteeringMode::StepBoundary, reasoning_levels: vec![ ReasoningLevel::Low, ReasoningLevel::Medium, @@ -713,17 +711,15 @@ pub fn default_registry() -> HarnessRegistry { Box::new(|| zeron_harness::PiHarness::new().installed()), Box::new(|| Ok(Arc::new(zeron_harness::PiHarness::new()) as Arc)), ); - // opencode over its NATIVE HTTP/SSE protocol (the one the opencode - // desktop app speaks — `opencode serve` + the /global/event bus), same - // lazy pattern: the static descriptor mirrors OpencodeHarness exactly. - // Turn-boundary steering; the effort ladder rides model VARIANTS (the - // run sends the first advertised variant id for the picked level). + // OpenCode uses its native HTTP/SSE protocol through opencode serve and the global event bus. + // The lazy descriptor matches OpencodeHarness. + // Steering applies at step boundaries. The model variant carries the effort choice. registry.register_lazy( HarnessDescriptor { id: HarnessId::Opencode, name: "OpenCode".into(), supports_steering: true, - steering_mode: SteeringMode::TurnBoundary, + steering_mode: SteeringMode::StepBoundary, reasoning_levels: vec![ ReasoningLevel::Low, ReasoningLevel::Medium, @@ -1208,6 +1204,21 @@ mod tests { assert_eq!(reloaded.enabled_set(), Vec::::new()); } + #[test] + fn every_lazy_steering_descriptor_matches_the_resolved_harness() { + let registry = default_registry(); + let mut mismatches = Vec::new(); + for before in registry.descriptors() { + let harness = registry.resolve(before.id).unwrap(); + if before.supports_steering != harness.supports_steering() + || before.steering_mode != harness.steering_mode() + { + mismatches.push((before.id, before.steering_mode, harness.steering_mode())); + } + } + assert!(mismatches.is_empty(), "{mismatches:?}"); + } + /// The Codex lazy descriptor must be indistinguishable from `describe()` /// after the first resolve — otherwise the catalog entry silently changes /// the moment the harness is used (name/ladder flip in the picker rail). diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 658db0e7d..870456d05 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -486,6 +486,7 @@ pub enum SendButtonMode { Send, /// Live run with text typed: queue for the next turn. Queue, + Steer, /// Live run, nothing typed: red stop square. Stop, } @@ -569,6 +570,21 @@ pub fn send_button_mode(run_live: bool, has_text: bool) -> SendButtonMode { } } +fn submission_mode( + run_live: bool, + has_content: bool, + auto_steer: bool, + steers_mid_turn: bool, + has_attachments: bool, +) -> SendButtonMode { + let mode = send_button_mode(run_live, has_content); + if mode == SendButtonMode::Queue && auto_steer && steers_mid_turn && !has_attachments { + SendButtonMode::Steer + } else { + mode + } +} + /// Queue rows are represented by the queue panel until the host promotes them /// into the transcript. They must never publish (or refresh) a local echo. fn should_publish_optimistic_echo(queue: bool) -> bool { @@ -7495,7 +7511,13 @@ impl Composer { self.staged().len() + self.staged_appshots().len(), self.staged_comments(cx).len(), ); - send_button_mode(self.run_live(cx), has_text) + submission_mode( + self.run_live(cx), + has_text, + crate::settings::current(cx).auto_steer, + self.pickers.read(cx).steers_mid_turn(cx), + !self.staged().is_empty() || !self.staged_appshots().is_empty(), + ) } fn execute_workspace_command( @@ -7553,9 +7575,10 @@ impl Composer { SendButtonMode::Stop => {} _ if no_content => {} _ if self.send_blocked(cx) => {} - SendButtonMode::Send => self.send(text, false, cx), + SendButtonMode::Send => self.send(text, SendButtonMode::Send, cx), // Busy: keep the message queued until the current turn ends. - SendButtonMode::Queue => self.send(text, true, cx), + SendButtonMode::Queue => self.send(text, SendButtonMode::Queue, cx), + SendButtonMode::Steer => self.send(text, SendButtonMode::Steer, cx), } } @@ -7582,7 +7605,7 @@ impl Composer { /// thread the picked config in: worktree creation (when the isolated toggle /// is on), `Mutate createChat` with the `ChatConfig` + cwd, and the model / /// reasoning / options on the Run request itself (§1.7). - fn send(&mut self, text: String, queue: bool, cx: &mut Context) { + fn send(&mut self, text: String, mode: SendButtonMode, cx: &mut Context) { if !self.check_reference_delivery(&text, cx) { return; } @@ -7656,7 +7679,7 @@ impl Composer { } state.unsaved_side_chat_create(&chat_id) }); - if queue && !is_new { + if mode == SendButtonMode::Queue && !is_new { let capability = if self.staged().is_empty() && self.staged_appshots().is_empty() { capabilities::MESSAGE_QUEUE_V1 } else { @@ -7701,9 +7724,9 @@ impl Composer { self.preview = None; let message_id = uuid::Uuid::new_v4().to_string(); let created_at = chrono::Utc::now().timestamp_millis(); - // Existing busy chats always queue; compatibility was checked before - // taking the draft, attachments, or review comments. - let queue = queue && !is_new; + // Compatibility was checked before taking the draft, attachments, + // or review comments. + let queue = mode == SendButtonMode::Queue && !is_new; let clean_queue_attachment_text = staged.is_empty() || (engine .engine_info() @@ -8186,22 +8209,29 @@ impl Composer { .as_ref() .and_then(|spec| spec.space_id.as_ref()) .is_some(); - let command = SessionCommandPayload::Run { - request: RunRequest { - mcp: None, + let command = if mode == SendButtonMode::Steer && !is_new { + SessionCommandPayload::Steer { prompt: content.clone(), - harness: resolved.harness, - model: resolved.model.clone(), - reasoning: resolved.reasoning, - model_options: resolved.model_options.clone(), - cwd, - sandbox: SandboxLevel::WorkspaceWrite, - auto_approve: false, - resume: None, - attachments: attachment_paths, - worktree: run_worktree, - }, - message_id: message_id.clone(), + message_id: Some(message_id.clone()), + } + } else { + SessionCommandPayload::Run { + request: RunRequest { + mcp: None, + prompt: content.clone(), + harness: resolved.harness, + model: resolved.model.clone(), + reasoning: resolved.reasoning, + model_options: resolved.model_options.clone(), + cwd, + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: false, + resume: None, + attachments: attachment_paths, + worktree: run_worktree, + }, + message_id: message_id.clone(), + } }; let command = serde_json::to_value(&command) .map_err(|e| format!("Send failed: {e}"))?; @@ -8823,7 +8853,7 @@ impl Composer { .tooltip(crate::settings::widgets::text_tooltip("Stop")) .child(div().size(px(11.0)).rounded(px(3.0)).bg(theme.bg)) .into_any_element(), - SendButtonMode::Send | SendButtonMode::Queue => { + SendButtonMode::Send | SendButtonMode::Queue | SendButtonMode::Steer => { // Share the submission guard with Enter, including pending // edits and the new-session runnable-agent check. let blocked = self.send_blocked(cx); @@ -8842,13 +8872,11 @@ impl Composer { .hover(|s| s.opacity(0.85)) .on_click(cx.listener(|this, _, _, cx| this.on_submit(cx))) }) - .tooltip(crate::settings::widgets::text_tooltip( - if mode == SendButtonMode::Queue { - "Queue message" - } else { - "Send message" - }, - )) + .tooltip(crate::settings::widgets::text_tooltip(match mode { + SendButtonMode::Queue => "Queue message", + SendButtonMode::Steer => "Steer active run", + _ => "Send message", + })) .child( crate::icons::icon(crate::icons::ARROW_UP) .size(px(14.0)) @@ -12515,7 +12543,7 @@ mod tests { })).unwrap()]; }); assert!(!composer.reference_delivery_supported(cx)); - composer.send(draft.clone(), false, cx); + composer.send(draft.clone(), SendButtonMode::Send, cx); assert!( composer .failure @@ -13651,6 +13679,153 @@ mod tests { ); } + #[gpui::test] + fn automatic_steering_dispatches_a_durable_steer_without_an_interrupt( + cx: &mut gpui::TestAppContext, + ) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _runtime = runtime.enter(); + let (_directory, window) = composer_focus_window(cx); + let (out, mut requests) = tokio::sync::mpsc::channel(64); + let (_replies, inbound) = tokio::sync::mpsc::channel(64); + window + .update(cx, |composer, _, cx| { + composer.state.update(cx, |state, _| { + state.set_test_engine(crate::state::EngineHandle::from_test_client( + zeron_rpc::RpcClient::new(out, inbound), + )); + state.local_device_id = Some("local".into()); + state.selected_chat = Some("active-chat".into()); + state.chats = vec![ + serde_json::from_value(serde_json::json!({ + "id": "active-chat", "deviceId": "local", "archived": false, + "createdAt": chrono::Utc::now(), "cwd": "/tmp/fixture", + "config": { "harness": "codex", "sandbox": "workspace-write" } + })) + .unwrap(), + ]; + }); + composer.send("Use the updated plan".into(), SendButtonMode::Steer, cx); + }) + .unwrap(); + cx.run_until_parked(); + let mut commands = Vec::new(); + while let Ok(frame) = requests.try_recv() { + let frame: zeron_rpc::ClientFrame = serde_json::from_str(&frame).unwrap(); + if matches!( + frame.method.as_deref(), + Some(methods::QUEUE_COMMAND | methods::QUEUE_MESSAGE) + ) { + commands.push(frame); + } + } + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].method.as_deref(), Some(methods::QUEUE_COMMAND)); + assert_eq!(commands[0].params["chatId"], "active-chat"); + assert_eq!(commands[0].params["command"]["kind"], "steer"); + assert_eq!( + commands[0].params["command"]["prompt"], + "Use the updated plan" + ); + assert!( + commands[0].params["command"]["messageId"] + .as_str() + .is_some() + ); + window + .update(cx, |composer, _, _| assert!(composer.sending)) + .unwrap(); + } + + #[test] + fn automatic_steering_covers_every_registered_provider_before_and_after_resolution() { + let registry = zeron_engine::registry::default_registry(); + let expected = [ + (HarnessId::Mock, true), + (HarnessId::ClaudeCode, true), + (HarnessId::Codex, true), + (HarnessId::Cursor, true), + (HarnessId::Devin, true), + (HarnessId::Grok, true), + (HarnessId::Hermes, false), + (HarnessId::Pi, true), + (HarnessId::Opencode, true), + (HarnessId::Antigravity, false), + ]; + let descriptors = registry.descriptors(); + assert_eq!(descriptors.len(), expected.len()); + for (id, mid_turn) in expected { + for resolved in [false, true] { + if resolved { + registry.resolve(id).unwrap(); + } + let descriptor = registry + .descriptors() + .into_iter() + .find(|d| d.id == id) + .unwrap(); + let capable = descriptor.steers_mid_turn(); + assert_eq!(capable, mid_turn, "{id:?}, resolved={resolved}"); + assert_eq!( + submission_mode(true, true, true, capable, false), + if mid_turn { + SendButtonMode::Steer + } else { + SendButtonMode::Queue + }, + "{id:?}, resolved={resolved}" + ); + assert_eq!( + submission_mode(true, true, false, capable, false), + SendButtonMode::Queue + ); + assert_eq!( + submission_mode(true, true, true, capable, true), + SendButtonMode::Queue + ); + assert_eq!( + submission_mode(false, true, true, capable, false), + SendButtonMode::Send + ); + assert_eq!( + submission_mode(true, false, true, capable, false), + SendButtonMode::Stop + ); + } + } + } + + #[test] + fn automatic_steering_requires_opt_in_and_a_capable_text_send() { + assert_eq!( + submission_mode(true, true, true, true, false), + SendButtonMode::Steer + ); + assert_eq!( + submission_mode(true, true, false, true, false), + SendButtonMode::Queue + ); + assert_eq!( + submission_mode(true, true, true, false, false), + SendButtonMode::Queue + ); + assert_eq!( + submission_mode(true, true, true, true, true), + SendButtonMode::Queue + ); + assert_eq!( + submission_mode(false, true, true, true, false), + SendButtonMode::Send + ); + assert_eq!( + submission_mode(true, false, true, true, false), + SendButtonMode::Stop + ); + } + #[test] fn send_button_morph() { assert_eq!(send_button_mode(false, false), SendButtonMode::Send); diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 6aaad42a3..8a1f0e1cb 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -789,6 +789,7 @@ pub struct UiSettings { pub window_geometry: Option, /// Submit using Enter or the platform modifier plus Enter. pub composer_send_behavior: ComposerSendBehavior, + pub auto_steer: bool, /// Legacy global opt-in; per-harness preferences take precedence. pub skills_in_slash_menu: bool, pub skill_completion_by_harness: @@ -1004,6 +1005,7 @@ impl Default for UiSettings { escape_stops_active_agent: false, settings_section: crate::shell::SettingsSection::default(), composer_send_behavior: ComposerSendBehavior::default(), + auto_steer: false, skills_in_slash_menu: false, skill_completion_by_harness: Default::default(), compact_model_picker: true, @@ -1852,6 +1854,21 @@ mod tests { assert!(!loaded.compact_model_picker); } + #[test] + fn automatic_steering_defaults_off_and_survives_a_settings_round_trip() { + let directory = tempfile::tempdir().unwrap(); + for document in [r#"{}"#, r#"{"sidebarWidth":300,"autoSteer":true}"#] { + std::fs::write(UiSettings::path(directory.path()), document).unwrap(); + let settings = UiSettings::load(directory.path()); + settings.save(directory.path()).unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(UiSettings::path(directory.path())).unwrap()) + .unwrap(); + assert_eq!(saved["autoSteer"], document.contains("true")); + assert_eq!(UiSettings::load(directory.path()), settings); + } + } + #[test] fn composer_send_behavior_is_opt_in_for_old_and_partial_settings() { let dir = tempfile::tempdir().unwrap(); @@ -2554,6 +2571,7 @@ mod tests { escape_stops_active_agent: true, settings_section: crate::shell::SettingsSection::Shortcuts, composer_send_behavior: ComposerSendBehavior::ModEnter, + auto_steer: true, skills_in_slash_menu: true, skill_completion_by_harness: Default::default(), compact_model_picker: true, diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index a461748d1..84d8d303a 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -49,6 +49,7 @@ pub enum ShortcutsEvent { EscapeStopsActiveAgentChanged(bool), /// The composer send behavior changed — persist + re-apply. ComposerSendBehaviorChanged(ComposerSendBehavior), + AutoSteerChanged(bool), AppshotsChanged { enabled: bool, sound_enabled: bool, @@ -65,6 +66,7 @@ pub struct ShortcutsPage { keymap: KeymapConfig, escape_stops_active_agent: bool, composer_send_behavior: ComposerSendBehavior, + auto_steer: bool, recording: Option, recording_blur: Option, recording_interceptor: Option, @@ -108,6 +110,7 @@ impl ShortcutsPage { keymap, escape_stops_active_agent, composer_send_behavior, + auto_steer: crate::settings::current(cx).auto_steer, recording: None, recording_blur: None, recording_interceptor: None, @@ -191,6 +194,14 @@ impl ShortcutsPage { } } + fn set_auto_steer(&mut self, enabled: bool, cx: &mut Context) { + if self.auto_steer != enabled { + self.auto_steer = enabled; + cx.emit(ShortcutsEvent::AutoSteerChanged(enabled)); + cx.notify(); + } + } + fn set_composer_send_behavior( &mut self, behavior: ComposerSendBehavior, @@ -523,6 +534,7 @@ impl Render for ShortcutsPage { let recording = self.recording; let escape_stops_active_agent = self.escape_stops_active_agent; let send_behavior = self.composer_send_behavior; + let auto_steer = self.auto_steer; let compact_mode = crate::settings::transcript_compact_mode(cx); let customized = self.keymap != KeymapConfig::default() || escape_stops_active_agent @@ -561,6 +573,37 @@ impl Render for ShortcutsPage { .child(widgets::row_title(&theme, "Send messages with")), ) .child(send_behavior_control); + let auto_steer_row = widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w_0() + .child(widgets::row_title(&theme, "Automatically steer messages")) + .child(widgets::meta_line( + &theme, + vec![div() + .child("Send messages to the active run. Images and agents that cannot steer use the queue.") + .into_any_element()], + )), + ) + .child( + widgets::toggle_switch(&theme, auto_steer, "auto-steer") + .id("auto-steer-toggle") + .debug_selector(|| "auto-steer-toggle".into()) + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Automatically steer messages") + .aria_toggled(if auto_steer { gpui::Toggled::True } else { gpui::Toggled::False }) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| this.set_auto_steer(!auto_steer, cx))) + .on_key_down(cx.listener(move |this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "space" | "enter") { + this.set_auto_steer(!auto_steer, cx); + cx.stop_propagation(); + } + })), + ); let compact_mode_row = widgets::card_row(&theme, false) .child( div() @@ -693,6 +736,7 @@ impl Render for ShortcutsPage { .child( widgets::section_card(&theme) .child(send_behavior_row) + .child(auto_steer_row) .child(compact_mode_row) .child(compact_model_picker_row) .child(escape_behavior_row), @@ -839,6 +883,37 @@ impl Render for ShortcutsPage { mod tests { use super::*; + #[gpui::test] + fn automatic_steering_toggle_works_with_pointer_and_keyboard(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + }); + let (page, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut page = ShortcutsPage::new( + state, + KeymapConfig::default(), + false, + ComposerSendBehavior::Enter, + false, + false, + AppshotDestination::Automatic, + cx, + ); + page.show_section(false, true); + page + }); + cx.update(|window, cx| window.draw(cx).clear()); + let toggle = cx.debug_bounds("auto-steer-toggle").unwrap(); + cx.simulate_click(toggle.center(), gpui::Modifiers::default()); + page.update(cx, |page, _| assert!(page.auto_steer)); + cx.simulate_keystrokes("space"); + page.update(cx, |page, _| assert!(!page.auto_steer)); + cx.simulate_keystrokes("enter"); + page.update(cx, |page, _| assert!(page.auto_steer)); + } + #[gpui::test] fn conversation_controls_work_after_moving_out_of_shortcuts(cx: &mut gpui::TestAppContext) { cx.update(|cx| { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b788e6674..c529a53e2 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -4933,6 +4933,9 @@ impl Shell { ShortcutsEvent::EscapeStopsActiveAgentChanged(enabled) => { this.settings.escape_stops_active_agent = *enabled; } + ShortcutsEvent::AutoSteerChanged(enabled) => { + this.settings.auto_steer = *enabled; + } ShortcutsEvent::ComposerSendBehaviorChanged(behavior) => { this.settings.composer_send_behavior = *behavior; }