diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs index d7e49535..177d60f3 100644 --- a/crates/agent/src/claude.rs +++ b/crates/agent/src/claude.rs @@ -317,6 +317,7 @@ fn launch_settings_json( thinking: Option, fast_mode: bool, ultracode: bool, + auto_compact_window: Option, ) -> Option { let mut settings = serde_json::Map::new(); if let Some(thinking) = thinking { @@ -328,6 +329,9 @@ fn launch_settings_json( if ultracode { settings.insert("ultracode".into(), json!(true)); } + if let Some(window) = auto_compact_window { + settings.insert("autoCompactWindow".into(), json!(window)); + } (!settings.is_empty()) .then(|| serde_json::to_string(&Value::Object(settings)).unwrap_or_default()) } @@ -341,14 +345,22 @@ impl ClaudeLaunchOptions { let ultracode = resolved_effort.as_deref() == Some("ultracode"); let effort = normalize_claude_cli_effort(resolved_effort.as_deref(), model); - // Model id: append `[1m]` when the 1M context window is selected. + let window = resolved_context_window(model.unwrap_or_default(), selections); + let native_window = native_context_window(model.unwrap_or_default()); + let effective_model_window = if native_window == 200_000 && window > native_window { + 1_000_000 + } else { + native_window + }; let model_id = model.map(|m| { - if selection_str(selections, "contextWindow").as_deref() == Some("1m") { - format!("{m}[1m]") + let base = m.strip_suffix("[1m]").unwrap_or(m); + if native_window == 200_000 && window > native_window { + format!("{base}[1m]") } else { - m.to_owned() + base.to_owned() } }); + let auto_compact_window = (window < effective_model_window).then_some(window); // `--settings` object: only supported/true keys are emitted. let fast_supported = spec @@ -366,7 +378,8 @@ impl ClaudeLaunchOptions { None }; - let settings_json = launch_settings_json(thinking, fast_mode, ultracode); + let settings_json = + launch_settings_json(thinking, fast_mode, ultracode, auto_compact_window); ClaudeLaunchOptions { model_id, @@ -2861,7 +2874,7 @@ fn reasoning(values: &[&str], default: &str) -> OptionDescriptor { } } -fn context_window() -> OptionDescriptor { +fn context_window(default: &str) -> OptionDescriptor { OptionDescriptor::Select { id: "contextWindow".to_owned(), label: "Context Window".to_owned(), @@ -2877,10 +2890,61 @@ fn context_window() -> OptionDescriptor { description: None, }, ], - default_value: Some("200k".to_owned()), + default_value: Some(default.to_owned()), + } +} + +/// Parse a Claude context-window selection into a validated token count. +pub fn parse_context_window_tokens(value: &Value) -> Option { + let tokens = match value { + Value::Number(number) => number.as_u64()?, + Value::String(value) => { + let value = value.trim().to_ascii_lowercase(); + if let Some(value) = value.strip_suffix('k') { + value.parse::().ok()?.checked_mul(1_000)? + } else if let Some(value) = value.strip_suffix('m') { + value.parse::().ok()?.checked_mul(1_000_000)? + } else { + let value = value.parse::().ok()?; + if value < 1_000 { + value.checked_mul(1_000)? + } else { + value + } + } + } + _ => return None, + }; + (100_000..=1_000_000).contains(&tokens).then_some(tokens) +} + +/// Return the model's native context-window size in tokens. +pub fn native_context_window(model_id: &str) -> u64 { + match model_id.strip_suffix("[1m]").unwrap_or(model_id) { + "claude-fable-5" | "claude-fable-5-1" | "claude-opus-5" | "claude-sonnet-5" + | "claude-opus-4-7" | "claude-opus-4-8" => 1_000_000, + _ => 200_000, } } +/// Format a context-window token count for display. +pub fn format_context_window(tokens: u64) -> String { + if tokens == 1_000_000 { + "1M".to_owned() + } else { + format!("{}k", tokens / 1_000) + } +} + +/// Resolve the selected context window, falling back to the model's native size. +pub fn resolved_context_window(model_id: &str, selections: &[OptionSelection]) -> u64 { + selections + .iter() + .find(|selection| selection.id == "contextWindow") + .and_then(|selection| parse_context_window_tokens(&selection.value)) + .unwrap_or_else(|| native_context_window(model_id)) +} + fn boolean(id: &str, label: &str) -> OptionDescriptor { OptionDescriptor::Boolean { id: id.to_owned(), @@ -2918,7 +2982,7 @@ fn built_in_models() -> Vec { ], "high", ), - context_window(), + context_window("1m"), ], ), model( @@ -2937,7 +3001,7 @@ fn built_in_models() -> Vec { ], "high", ), - context_window(), + context_window("1m"), ], ), model( @@ -2957,7 +3021,7 @@ fn built_in_models() -> Vec { "high", ), boolean("fastMode", "Fast Mode"), - context_window(), + context_window("1m"), ], ), model( @@ -2977,6 +3041,7 @@ fn built_in_models() -> Vec { "high", ), boolean("fastMode", "Fast Mode"), + context_window("1m"), ], ), model( @@ -2988,6 +3053,7 @@ fn built_in_models() -> Vec { "xhigh", ), boolean("fastMode", "Fast Mode"), + context_window("1m"), ], ), model( @@ -2996,7 +3062,7 @@ fn built_in_models() -> Vec { vec![ reasoning(&["low", "medium", "high", "max", "ultrathink"], "high"), boolean("fastMode", "Fast Mode"), - context_window(), + context_window("200k"), ], ), model( @@ -3015,7 +3081,7 @@ fn built_in_models() -> Vec { &["low", "medium", "high", "xhigh", "max", "ultrathink"], "high", ), - context_window(), + context_window("1m"), ], ), model( @@ -3023,7 +3089,7 @@ fn built_in_models() -> Vec { "Claude Sonnet 4.6", vec![ reasoning(&["low", "medium", "high", "max", "ultrathink"], "high"), - context_window(), + context_window("200k"), ], ), model( @@ -3490,9 +3556,114 @@ mod tests { assert_eq!(crate::process::parse_semver("nonsense"), None); } + #[test] + fn parse_context_window_values() { + assert_eq!(parse_context_window_tokens(&json!("200k")), Some(200_000)); + assert_eq!(parse_context_window_tokens(&json!("1m")), Some(1_000_000)); + assert_eq!(parse_context_window_tokens(&json!("1M")), Some(1_000_000)); + assert_eq!(parse_context_window_tokens(&json!("500k")), Some(500_000)); + assert_eq!(parse_context_window_tokens(&json!("500000")), Some(500_000)); + assert_eq!(parse_context_window_tokens(&json!("500")), Some(500_000)); + assert_eq!(parse_context_window_tokens(&json!(750_000)), Some(750_000)); + assert_eq!(parse_context_window_tokens(&json!(99_999)), None); + assert_eq!(parse_context_window_tokens(&json!(1_000_001)), None); + assert_eq!(parse_context_window_tokens(&json!("99k")), None); + assert_eq!(parse_context_window_tokens(&json!("1001k")), None); + assert_eq!(parse_context_window_tokens(&json!("garbage")), None); + assert_eq!(parse_context_window_tokens(&json!(-200_000)), None); + assert_eq!(parse_context_window_tokens(&json!(null)), None); + assert_eq!(native_context_window("claude-opus-5[1m]"), 1_000_000); + assert_eq!(native_context_window("claude-sonnet-4-6[1m]"), 200_000); + assert_eq!(format_context_window(200_000), "200k"); + assert_eq!(format_context_window(750_000), "750k"); + assert_eq!(format_context_window(1_000_000), "1M"); + } + + #[test] + fn catalog_context_window_defaults_match_native_windows() { + let default = |model_id: &str| { + model_spec(model_id) + .unwrap() + .options + .into_iter() + .find_map(|option| match option { + OptionDescriptor::Select { + id, default_value, .. + } if id == "contextWindow" => default_value, + _ => None, + }) + }; + + for model_id in [ + "claude-fable-5", + "claude-fable-5-1", + "claude-opus-5", + "claude-sonnet-5", + "claude-opus-4-7", + "claude-opus-4-8", + ] { + assert_eq!(default(model_id).as_deref(), Some("1m")); + } + for model_id in ["claude-sonnet-4-6", "claude-opus-4-6"] { + assert_eq!(default(model_id).as_deref(), Some("200k")); + } + assert_eq!(default("claude-haiku-4-5"), None); + assert_eq!(default("claude-opus-4-5"), None); + } + + #[test] + fn context_window_launch_semantics() { + let resolve = |model, value: Option| { + let selections = value + .map(|value| { + vec![OptionSelection { + id: "contextWindow".into(), + value, + }] + }) + .unwrap_or_default(); + ClaudeLaunchOptions::resolve(Some(model), &selections) + }; + let auto_compact = |launch: &ClaudeLaunchOptions| { + launch.settings_json.as_deref().map(|settings| { + serde_json::from_str::(settings).unwrap()["autoCompactWindow"].clone() + }) + }; + + let launch = resolve("claude-opus-5", Some(json!("200k"))); + assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); + assert_eq!(auto_compact(&launch), Some(json!(200_000))); + + let launch = resolve("claude-opus-5", Some(json!("1m"))); + assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); + assert!(launch.settings_json.is_none()); + + let launch = resolve("claude-opus-5", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("claude-opus-5")); + assert_eq!(auto_compact(&launch), Some(json!(500_000))); + + let launch = resolve("claude-sonnet-4-6", Some(json!("1m"))); + assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6[1m]")); + assert!(launch.settings_json.is_none()); + + let launch = resolve("claude-sonnet-4-6", Some(json!(500_000))); + assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6[1m]")); + assert_eq!(auto_compact(&launch), Some(json!(500_000))); + + for value in [Some(json!("200k")), None] { + let launch = resolve("claude-sonnet-4-6", value); + assert_eq!(launch.model_id.as_deref(), Some("claude-sonnet-4-6")); + assert!(launch.settings_json.is_none()); + } + + let launch = resolve("claude-fable-5", Some(json!("1m"))); + assert_eq!(launch.model_id.as_deref(), Some("claude-fable-5")); + assert!(launch.settings_json.is_none()); + } + #[test] fn launch_options_resolve_effort_context_and_settings() { - // 1M context suffix + ultracode → effort xhigh + settings.ultracode. + // Ultracode → effort xhigh + settings.ultracode. let launch = ClaudeLaunchOptions::resolve( Some("claude-opus-4-8"), &[ @@ -3528,7 +3699,7 @@ mod tests { }, ], ); - assert_eq!(launch.model_id.as_deref(), Some("claude-fable-5[1m]")); + assert_eq!(launch.model_id.as_deref(), Some("claude-fable-5")); assert_eq!(launch.effort, None); assert!(launch.ultrathink); assert!(launch.settings_json.is_none()); diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index bc51bf31..94390360 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -1171,6 +1171,11 @@ pub enum AgentEvent { /// The provider compacted its context window (Claude `system/compact_boundary`; /// Codex `contextCompaction` item). Rendered as a "Context compacted" work-log row. ContextCompacted, + /// A tcode-level context-window change. This is never emitted by an adapter; + /// the runtime persists it after the user message that selected the window. + ContextWindowChanged { + window: u64, + }, /// Structured plan / task list for the sidebar (Codex `turn/plan/updated`, /// Claude `TodoWrite`). Replaces the current turn's plan wholesale. PlanUpdated { diff --git a/crates/computer-use-mcp/src/backend/macos/mod.rs b/crates/computer-use-mcp/src/backend/macos/mod.rs index ca009ad4..f4bcd101 100644 --- a/crates/computer-use-mcp/src/backend/macos/mod.rs +++ b/crates/computer-use-mcp/src/backend/macos/mod.rs @@ -445,21 +445,17 @@ fn reflect_overlay(root: &RootInfo, request: &ActionRequest) { return; } use overlay::OverlayActionKind as K; - let frame = root.frame; match request.kind { ActionKind::Drag => { if let Some(path) = request.path.as_ref() && let (Some(first), Some(last)) = (path.first(), path.last()) { - overlay::show_drag((first[0], first[1]), (last[0], last[1]), frame); - return; + overlay::show_drag(root.pid, (first[0], first[1]), (last[0], last[1])); } - overlay::highlight_window(frame); } ActionKind::TypeText | ActionKind::SetText | ActionKind::Keypress => { - match action_point(root, request) { - Ok(point) => overlay::show_action(K::Keyboard, point, frame), - Err(_) => overlay::highlight_window(frame), + if let Ok(point) = action_point(root, request) { + overlay::show_action(root.pid, K::Keyboard, point); } } other => { @@ -468,9 +464,8 @@ fn reflect_overlay(root: &RootInfo, request: &ActionRequest) { ActionKind::MoveMouse => K::Move, _ => K::Click, }; - match action_point(root, request) { - Ok(point) => overlay::show_action(kind, point, frame), - Err(_) => overlay::highlight_window(frame), + if let Ok(point) = action_point(root, request) { + overlay::show_action(root.pid, kind, point); } } } diff --git a/crates/computer-use-mcp/src/backend/macos/overlay/border.rs b/crates/computer-use-mcp/src/backend/macos/overlay/border.rs deleted file mode 100644 index d525af1c..00000000 --- a/crates/computer-use-mcp/src/backend/macos/overlay/border.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::ptr; - -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - -use super::ffi::{ - Id, class, send_id, send_id_color, send_id_cstr, send_id_f32, send_id_id, send_id_objects, - send_id_rect, send_id_rounded_rect, send_id_window_init, send_void, send_void_bool, - send_void_f32, send_void_f64, send_void_id, send_void_isize, send_void_point, send_void_rect, - send_void_rect_bool, send_void_size, send_void_two_ids, send_void_usize, status_window_level, -}; -use super::geometry::{BORDER_PADDING, DisplayGeometry, border_frame}; -use crate::outline::Frame; - -const NS_WINDOW_STYLE_BORDERLESS: usize = 0; -const NS_BACKING_STORE_BUFFERED: usize = 2; -const NS_WINDOW_COLLECTION_BEHAVIOR: usize = (1 << 0) | (1 << 3) | (1 << 9); -const FADE_DURATION: f64 = 0.3; -const CORNER_RADIUS: f64 = 12.0; - -pub(super) struct BorderUi { - window: Id, - container: Id, - glow: Id, - gradient: Id, - mask: Id, - visible: bool, -} - -impl BorderUi { - /// Must only be called from the process main queue. - pub(super) fn new() -> Option { - let window_class = class(c"NSWindow")?; - let allocated = send_id(window_class, c"alloc")?; - let window = send_id_window_init( - allocated, - c"initWithContentRect:styleMask:backing:defer:", - rect(0.0, 0.0, 1.0, 1.0), - NS_WINDOW_STYLE_BORDERLESS, - NS_BACKING_STORE_BUFFERED, - false, - )?; - let clear = send_id(class(c"NSColor")?, c"clearColor")?; - let configured = send_void_bool(window, c"setReleasedWhenClosed:", false) - && send_void_bool(window, c"setIgnoresMouseEvents:", true) - && send_void_bool(window, c"setOpaque:", false) - && send_void_bool(window, c"setHasShadow:", false) - && send_void_bool(window, c"setHidesOnDeactivate:", false) - && send_void_id(window, c"setBackgroundColor:", clear) - && send_void_usize( - window, - c"setCollectionBehavior:", - NS_WINDOW_COLLECTION_BEHAVIOR, - ) - && send_void_isize(window, c"setLevel:", status_window_level()); - if !configured { - return None; - } - - let view = send_id_rect( - send_id(class(c"NSView")?, c"alloc")?, - c"initWithFrame:", - rect(0.0, 0.0, 1.0, 1.0), - )?; - if !send_void_bool(view, c"setWantsLayer:", true) { - return None; - } - let root = send_id(view, c"layer")?; - let container = send_id(class(c"CALayer")?, c"layer")?; - let glow = send_id(class(c"CAShapeLayer")?, c"layer")?; - let gradient = send_id(class(c"CAGradientLayer")?, c"layer")?; - let mask = send_id(class(c"CAShapeLayer")?, c"layer")?; - - let glow_color = cg_color(0.91, 0.25, 0.72, 0.34)?; - let shadow_color = cg_color(0.30, 0.68, 1.0, 0.9)?; - let mask_color = cg_color(1.0, 1.0, 1.0, 1.0)?; - let colors = gradient_colors()?; - - let layers_configured = send_void_f32(container, c"setOpacity:", 0.0) - && send_void_id(glow, c"setFillColor:", ptr::null_mut()) - && send_void_id(glow, c"setStrokeColor:", glow_color) - && send_void_f64(glow, c"setLineWidth:", 13.0) - && send_void_id(glow, c"setShadowColor:", shadow_color) - && send_void_f32(glow, c"setShadowOpacity:", 0.75) - && send_void_f64(glow, c"setShadowRadius:", 22.0) - && send_void_size(glow, c"setShadowOffset:", CGSize::new(0.0, 0.0)) - && send_void_id(gradient, c"setColors:", colors) - && send_void_point(gradient, c"setStartPoint:", CGPoint::new(0.0, 0.25)) - && send_void_point(gradient, c"setEndPoint:", CGPoint::new(1.0, 0.75)) - && send_void_id(mask, c"setFillColor:", ptr::null_mut()) - && send_void_id(mask, c"setStrokeColor:", mask_color) - && send_void_f64(mask, c"setLineWidth:", 6.0) - && send_void_id(gradient, c"setMask:", mask) - && send_void_id(container, c"addSublayer:", glow) - && send_void_id(container, c"addSublayer:", gradient) - && send_void_id(root, c"addSublayer:", container) - && send_void_id(window, c"setContentView:", view); - if !layers_configured { - return None; - } - - Some(Self { - window, - container, - glow, - gradient, - mask, - visible: false, - }) - } - - /// Must only be called from the process main queue. - pub(super) fn show(&mut self, window_frame: Frame, display: DisplayGeometry) { - let outer = border_frame(window_frame, display); - let outer_rect = frame_rect(outer); - let bounds = rect(0.0, 0.0, outer.w, outer.h); - let inner = rect( - BORDER_PADDING, - BORDER_PADDING, - window_frame.w, - window_frame.h, - ); - let Some(path) = send_id_rounded_rect( - class(c"NSBezierPath").unwrap_or(ptr::null_mut()), - c"bezierPathWithRoundedRect:xRadius:yRadius:", - inner, - CORNER_RADIUS, - CORNER_RADIUS, - ) - .and_then(|path| send_id(path, c"CGPath")) else { - return; - }; - - let transaction = begin_without_implicit_animations(); - let updated = send_void_rect_bool(self.window, c"setFrame:display:", outer_rect, true) - && send_void_rect(self.container, c"setFrame:", bounds) - && send_void_rect(self.glow, c"setFrame:", bounds) - && send_void_rect(self.gradient, c"setFrame:", bounds) - && send_void_rect(self.mask, c"setFrame:", bounds) - && send_void_id(self.glow, c"setPath:", path) - && send_void_id(self.mask, c"setPath:", path); - end_transaction(transaction); - if !updated { - return; - } - - let _ = send_void(self.window, c"orderFrontRegardless"); - let from = if self.visible { 1.0 } else { 0.0 }; - animate_opacity(self.container, from, 1.0); - self.visible = true; - } - - /// Must only be called from the process main queue. - pub(super) fn hide(&mut self) { - if self.visible { - animate_opacity(self.container, 1.0, 0.0); - self.visible = false; - } - } -} - -fn gradient_colors() -> Option { - let colors = [ - cg_color(0.98, 0.66, 0.26, 0.95)?, - cg_color(0.94, 0.29, 0.48, 0.96)?, - cg_color(0.75, 0.42, 0.96, 0.94)?, - cg_color(0.31, 0.72, 1.0, 0.95)?, - ]; - send_id_objects( - class(c"NSArray")?, - c"arrayWithObjects:count:", - colors.as_ptr(), - colors.len(), - ) -} - -fn animate_opacity(layer: Id, from: f32, to: f32) { - let Some(key) = ns_string(c"tcode.agent-overlay.opacity") else { - let _ = send_void_f32(layer, c"setOpacity:", to); - return; - }; - let Some(key_path) = ns_string(c"opacity") else { - let _ = send_void_f32(layer, c"setOpacity:", to); - return; - }; - let Some(animation) = send_id_id( - class(c"CABasicAnimation").unwrap_or(ptr::null_mut()), - c"animationWithKeyPath:", - key_path, - ) else { - let _ = send_void_f32(layer, c"setOpacity:", to); - return; - }; - let Some(from_value) = number(from) else { - let _ = send_void_f32(layer, c"setOpacity:", to); - return; - }; - let Some(to_value) = number(to) else { - let _ = send_void_f32(layer, c"setOpacity:", to); - return; - }; - - let configured = send_void_id(animation, c"setFromValue:", from_value) - && send_void_id(animation, c"setToValue:", to_value) - && send_void_f64(animation, c"setDuration:", FADE_DURATION); - if let Some(timing) = timing_function() { - let _ = send_void_id(animation, c"setTimingFunction:", timing); - } - - let transaction = begin_without_implicit_animations(); - let model_updated = send_void_f32(layer, c"setOpacity:", to); - end_transaction(transaction); - if configured && model_updated { - let _ = send_void_two_ids(layer, c"addAnimation:forKey:", animation, key); - } -} - -fn begin_without_implicit_animations() -> Option { - let transaction = class(c"CATransaction")?; - if !send_void(transaction, c"begin") { - return None; - } - let _ = send_void_bool(transaction, c"setDisableActions:", true); - Some(transaction) -} - -fn end_transaction(transaction: Option) { - if let Some(transaction) = transaction { - let _ = send_void(transaction, c"commit"); - } -} - -fn timing_function() -> Option { - let name = ns_string(c"easeInEaseOut")?; - send_id_id(class(c"CAMediaTimingFunction")?, c"functionWithName:", name) -} - -fn number(value: f32) -> Option { - send_id_f32(class(c"NSNumber")?, c"numberWithFloat:", value) -} - -fn ns_string(value: &std::ffi::CStr) -> Option { - send_id_cstr( - class(c"NSString")?, - c"stringWithUTF8String:", - value.as_ptr(), - ) -} - -fn cg_color(red: f64, green: f64, blue: f64, alpha: f64) -> Option { - let color = send_id_color( - class(c"NSColor")?, - c"colorWithSRGBRed:green:blue:alpha:", - red, - green, - blue, - alpha, - )?; - send_id(color, c"CGColor") -} - -fn frame_rect(frame: Frame) -> CGRect { - rect(frame.x, frame.y, frame.w, frame.h) -} - -fn rect(x: f64, y: f64, width: f64, height: f64) -> CGRect { - CGRect::new(&CGPoint::new(x, y), &CGSize::new(width, height)) -} diff --git a/crates/computer-use-mcp/src/backend/macos/overlay/cursor.rs b/crates/computer-use-mcp/src/backend/macos/overlay/cursor.rs index 6c6ae368..f515f051 100644 --- a/crates/computer-use-mcp/src/backend/macos/overlay/cursor.rs +++ b/crates/computer-use-mcp/src/backend/macos/overlay/cursor.rs @@ -103,17 +103,18 @@ impl CursorUi { kind: OverlayActionKind, ax_point: (f64, f64), display: DisplayGeometry, + visible: bool, ) { self.set_kind(kind); let appkit_point = ax_screen_to_appkit(ax_point, display); - if !self.visible { - let spawn = (display.appkit.x - 48.0, display.appkit.y - 48.0); - let _ = send_void_point(self.window, c"setFrameOrigin:", window_origin(spawn)); - self.visible = true; + if self.visible { + animate_window_origin(self.window, window_origin(appkit_point)); + } else { + // A hidden panel has no on-screen position to slide from: land on + // the point, then reveal. + let _ = send_void_point(self.window, c"setFrameOrigin:", window_origin(appkit_point)); } - let _ = send_void(self.window, c"orderFrontRegardless"); - let _ = send_void(self.window, c"displayIfNeeded"); - animate_window_origin(self.window, window_origin(appkit_point)); + self.set_visible(visible); } /// Must only be called from the process main queue. @@ -123,23 +124,33 @@ impl CursorUi { to_ax: (f64, f64), from_display: DisplayGeometry, to_display: DisplayGeometry, + visible: bool, ) { self.set_kind(OverlayActionKind::Drag); let from = ax_screen_to_appkit(from_ax, from_display); let to = ax_screen_to_appkit(to_ax, to_display); let _ = send_void_point(self.window, c"setFrameOrigin:", window_origin(from)); - let _ = send_void(self.window, c"orderFrontRegardless"); - let _ = send_void(self.window, c"displayIfNeeded"); - self.visible = true; + self.set_visible(visible); animate_window_origin(self.window, window_origin(to)); } /// Must only be called from the process main queue. - pub(super) fn hide(&mut self) { - if self.visible { + pub(super) fn set_visible(&mut self, visible: bool) { + if visible == self.visible { + return; + } + if visible { + let _ = send_void(self.window, c"orderFrontRegardless"); + let _ = send_void(self.window, c"displayIfNeeded"); + } else { let _ = send_void_id(self.window, c"orderOut:", ptr::null_mut()); - self.visible = false; } + self.visible = visible; + } + + /// Must only be called from the process main queue. + pub(super) fn hide(&mut self) { + self.set_visible(false); } fn set_kind(&self, kind: OverlayActionKind) { diff --git a/crates/computer-use-mcp/src/backend/macos/overlay/ffi.rs b/crates/computer-use-mcp/src/backend/macos/overlay/ffi.rs index e9dc770c..d699e158 100644 --- a/crates/computer-use-mcp/src/backend/macos/overlay/ffi.rs +++ b/crates/computer-use-mcp/src/backend/macos/overlay/ffi.rs @@ -46,6 +46,8 @@ unsafe extern "C" { unsafe extern "C" { static _dispatch_main_q: c_void; fn dispatch_async_f(queue: Id, context: *mut c_void, work: DispatchFn); + fn dispatch_after_f(when: u64, queue: Id, context: *mut c_void, work: DispatchFn); + fn dispatch_time(when: u64, delta: i64) -> u64; } macro_rules! invoke { @@ -109,6 +111,15 @@ pub(super) fn send_id_id(receiver: Id, name: &CStr, value: Id) -> Option { (!result.is_null()).then_some(result) } +pub(super) fn send_id_i32(receiver: Id, name: &CStr, value: i32) -> Option { + let selector = selector(name)?; + if !can_send(receiver, selector) { + return None; + } + let result = invoke!(Id, receiver, selector, i32 => value); + (!result.is_null()).then_some(result) +} + pub(super) fn send_id_rect(receiver: Id, name: &CStr, rect: CGRect) -> Option { let selector = selector(name)?; if !can_send(receiver, selector) { @@ -142,28 +153,6 @@ pub(super) fn send_id_window_init( (!value.is_null()).then_some(value) } -pub(super) fn send_id_rounded_rect( - receiver: Id, - name: &CStr, - rect: CGRect, - x_radius: f64, - y_radius: f64, -) -> Option { - let selector = selector(name)?; - if !can_send(receiver, selector) { - return None; - } - let value = invoke!( - Id, - receiver, - selector, - CGRect => rect, - f64 => x_radius, - f64 => y_radius, - ); - (!value.is_null()).then_some(value) -} - pub(super) fn send_id_color( receiver: Id, name: &CStr, @@ -188,35 +177,6 @@ pub(super) fn send_id_color( (!value.is_null()).then_some(value) } -pub(super) fn send_id_f32(receiver: Id, name: &CStr, value: f32) -> Option { - let selector = selector(name)?; - if !can_send(receiver, selector) { - return None; - } - let result = invoke!(Id, receiver, selector, f32 => value); - (!result.is_null()).then_some(result) -} - -pub(super) fn send_id_objects( - receiver: Id, - name: &CStr, - objects: *const Id, - count: usize, -) -> Option { - let selector = selector(name)?; - if !can_send(receiver, selector) || (objects.is_null() && count != 0) { - return None; - } - let value = invoke!( - Id, - receiver, - selector, - *const Id => objects, - usize => count, - ); - (!value.is_null()).then_some(value) -} - pub(super) fn send_void(receiver: Id, name: &CStr) -> bool { let Some(selector) = selector(name) else { return false; @@ -239,17 +199,6 @@ pub(super) fn send_void_id(receiver: Id, name: &CStr, value: Id) -> bool { true } -pub(super) fn send_void_two_ids(receiver: Id, name: &CStr, first: Id, second: Id) -> bool { - let Some(selector) = selector(name) else { - return false; - }; - if !can_send(receiver, selector) { - return false; - } - invoke!((), receiver, selector, Id => first, Id => second); - true -} - pub(super) fn send_void_bool(receiver: Id, name: &CStr, value: bool) -> bool { let Some(selector) = selector(name) else { return false; @@ -338,25 +287,28 @@ pub(super) fn send_void_rect(receiver: Id, name: &CStr, value: CGRect) -> bool { true } -pub(super) fn send_void_rect_bool(receiver: Id, name: &CStr, rect: CGRect, value: bool) -> bool { - let Some(selector) = selector(name) else { - return false; - }; - if !can_send(receiver, selector) { +pub(super) fn dispatch_main(context: *mut c_void, work: DispatchFn) -> bool { + let queue = dispatch_get_main_queue(); + if queue.is_null() { return false; } - invoke!((), receiver, selector, CGRect => rect, i8 => i8::from(value)); + // SAFETY: the caller owns context until work runs; libdispatch invokes work + // exactly once with that unchanged context on the main queue. + unsafe { dispatch_async_f(queue, context, work) }; true } -pub(super) fn dispatch_main(context: *mut c_void, work: DispatchFn) -> bool { +pub(super) fn dispatch_main_after(delay_ns: i64, work: DispatchFn) -> bool { let queue = dispatch_get_main_queue(); if queue.is_null() { return false; } - // SAFETY: the caller owns context until work runs; libdispatch invokes work - // exactly once with that unchanged context on the main queue. - unsafe { dispatch_async_f(queue, context, work) }; + // SAFETY: queue is the process main queue, dispatch_time accepts a relative + // nanosecond delta from DISPATCH_TIME_NOW, and the null context needs no owner. + unsafe { + let when = dispatch_time(0, delay_ns); + dispatch_after_f(when, queue, std::ptr::null_mut(), work); + } true } diff --git a/crates/computer-use-mcp/src/backend/macos/overlay/geometry.rs b/crates/computer-use-mcp/src/backend/macos/overlay/geometry.rs index 3e2aa5b9..24b67d80 100644 --- a/crates/computer-use-mcp/src/backend/macos/overlay/geometry.rs +++ b/crates/computer-use-mcp/src/backend/macos/overlay/geometry.rs @@ -1,7 +1,5 @@ use crate::outline::Frame; -pub(super) const BORDER_PADDING: f64 = 80.0; - #[derive(Clone, Copy)] pub(super) struct DisplayGeometry { pub(super) ax: Frame, @@ -17,49 +15,16 @@ pub(super) fn ax_screen_to_appkit(point: (f64, f64), display: DisplayGeometry) - ) } -fn ax_frame_to_appkit(frame: Frame, display: DisplayGeometry) -> Frame { - let (left, bottom) = ax_screen_to_appkit((frame.x, frame.y + frame.h), display); - Frame { - x: left, - y: bottom, - w: frame.w, - h: frame.h, - } -} - -pub(super) fn border_frame(window_frame: Frame, display: DisplayGeometry) -> Frame { - let appkit = ax_frame_to_appkit(window_frame, display); - Frame { - x: appkit.x - BORDER_PADDING, - y: appkit.y - BORDER_PADDING, - w: appkit.w + BORDER_PADDING * 2.0, - h: appkit.h + BORDER_PADDING * 2.0, - } -} - pub(super) fn is_finite_point(point: (f64, f64)) -> bool { point.0.is_finite() && point.1.is_finite() } -pub(super) fn is_valid_frame(frame: Frame) -> bool { - frame.x.is_finite() - && frame.y.is_finite() - && frame.w.is_finite() - && frame.h.is_finite() - && (frame.x + frame.w).is_finite() - && (frame.y + frame.h).is_finite() - && (frame.w + BORDER_PADDING * 2.0).is_finite() - && (frame.h + BORDER_PADDING * 2.0).is_finite() - && frame.w > 0.0 - && frame.h > 0.0 -} - #[cfg(test)] mod tests { use super::*; #[test] - fn flips_ax_geometry_and_expands_border_on_the_containing_display() { + fn flips_ax_geometry_on_the_containing_display() { let display = DisplayGeometry { ax: Frame { x: 0.0, @@ -76,24 +41,6 @@ mod tests { }; assert_eq!(ax_screen_to_appkit((100.0, 250.0), display), (100.0, 650.0)); - assert_eq!( - border_frame( - Frame { - x: 100.0, - y: 200.0, - w: 500.0, - h: 400.0, - }, - display, - ), - Frame { - x: 20.0, - y: 220.0, - w: 660.0, - h: 560.0, - } - ); - let offset_display = DisplayGeometry { ax: Frame { x: 1_440.0, diff --git a/crates/computer-use-mcp/src/backend/macos/overlay/mod.rs b/crates/computer-use-mcp/src/backend/macos/overlay/mod.rs index ae889733..94d9af36 100644 --- a/crates/computer-use-mcp/src/backend/macos/overlay/mod.rs +++ b/crates/computer-use-mcp/src/backend/macos/overlay/mod.rs @@ -1,6 +1,3 @@ -#![allow(dead_code)] - -mod border; mod cursor; mod ffi; mod geometry; @@ -9,12 +6,10 @@ use std::cell::RefCell; use std::ffi::c_void; use std::sync::atomic::{AtomicBool, Ordering}; -use border::BorderUi; use cursor::CursorUi; use self::ffi::{class, dispatch_main, send_id, send_void}; -use self::geometry::{is_finite_point, is_valid_frame}; -use crate::outline::Frame; +use self::geometry::is_finite_point; static ENABLED: AtomicBool = AtomicBool::new(false); @@ -30,74 +25,55 @@ pub(crate) enum OverlayActionKind { pub(crate) fn set_enabled(on: bool) { let was_enabled = ENABLED.swap(on, Ordering::AcqRel); if was_enabled && !on { - enqueue(UiCommand::Clear); + clear(); } } -pub(crate) fn show_action( - kind: OverlayActionKind, - ax_screen_point: (f64, f64), - window_frame: Frame, -) { +pub(crate) fn show_action(pid: u32, kind: OverlayActionKind, ax_screen_point: (f64, f64)) { if !ENABLED.load(Ordering::Acquire) || !is_finite_point(ax_screen_point) { return; } enqueue(UiCommand::ShowAction { + pid, kind, point: ax_screen_point, - window_frame, }); } -pub(crate) fn show_drag(from: (f64, f64), to: (f64, f64), window_frame: Frame) { +pub(crate) fn show_drag(pid: u32, from: (f64, f64), to: (f64, f64)) { if !ENABLED.load(Ordering::Acquire) || !is_finite_point(from) || !is_finite_point(to) { return; } - enqueue(UiCommand::ShowDrag { - from, - to, - window_frame, - }); -} - -pub(crate) fn highlight_window(window_frame: Frame) { - if !ENABLED.load(Ordering::Acquire) || !is_valid_frame(window_frame) { - return; - } - enqueue(UiCommand::Highlight(window_frame)); + enqueue(UiCommand::ShowDrag { pid, from, to }); } pub(crate) fn clear() { - if ENABLED.load(Ordering::Acquire) { - enqueue(UiCommand::Clear); - } + enqueue(UiCommand::Clear); } enum UiCommand { ShowAction { + pid: u32, kind: OverlayActionKind, point: (f64, f64), - window_frame: Frame, }, ShowDrag { + pid: u32, from: (f64, f64), to: (f64, f64), - window_frame: Frame, }, - Highlight(Frame), Clear, } struct OverlayState { cursor: Option, - border: Option, + target_pid: Option, + poll_armed: bool, } impl OverlayState { - fn show_action(&mut self, kind: OverlayActionKind, point: (f64, f64), window_frame: Frame) { - if is_valid_frame(window_frame) { - self.highlight(window_frame); - } + fn show_action(&mut self, pid: u32, kind: OverlayActionKind, point: (f64, f64)) { + self.set_target(pid); let Some(display) = ffi::display_frame_for_ax_point(point) else { return; }; @@ -105,14 +81,12 @@ impl OverlayState { self.cursor = CursorUi::new(); } if let Some(cursor) = self.cursor.as_mut() { - cursor.show(kind, point, display); + cursor.show(kind, point, display, is_target_frontmost(pid)); } } - fn show_drag(&mut self, from: (f64, f64), to: (f64, f64), window_frame: Frame) { - if is_valid_frame(window_frame) { - self.highlight(window_frame); - } + fn show_drag(&mut self, pid: u32, from: (f64, f64), to: (f64, f64)) { + self.set_target(pid); let Some(from_display) = ffi::display_frame_for_ax_point(from) else { return; }; @@ -121,19 +95,30 @@ impl OverlayState { self.cursor = CursorUi::new(); } if let Some(cursor) = self.cursor.as_mut() { - cursor.show_drag(from, to, from_display, to_display); + cursor.show_drag(from, to, from_display, to_display, is_target_frontmost(pid)); } } - fn highlight(&mut self, window_frame: Frame) { - let Some(display) = ffi::display_frame_for_ax_point(window_frame.center()) else { + fn set_target(&mut self, pid: u32) { + self.target_pid = Some(pid); + if !self.poll_armed + && ffi::dispatch_main_after(FOREGROUND_POLL_INTERVAL_NS, poll_foreground) + { + self.poll_armed = true; + } + } + + fn refresh_visibility(&mut self) { + let Some(pid) = self.target_pid else { return; }; - if self.border.is_none() { - self.border = BorderUi::new(); + let is_frontmost = is_target_frontmost(pid); + if !is_frontmost && !target_process_exists(pid) { + self.clear(); + return; } - if let Some(border) = self.border.as_mut() { - border.show(window_frame, display); + if let Some(cursor) = self.cursor.as_mut() { + cursor.set_visible(is_frontmost); } } @@ -141,20 +126,38 @@ impl OverlayState { if let Some(cursor) = self.cursor.as_mut() { cursor.hide(); } - if let Some(border) = self.border.as_mut() { - border.hide(); - } + self.target_pid = None; } } -// This thread-local is intentionally read only by `run_command`, which is -// exclusively submitted to the process main queue. Objective-C window and -// layer pointers therefore never cross back into background-thread UI code. +const FOREGROUND_POLL_INTERVAL_NS: i64 = 200_000_000; + +fn is_target_frontmost(pid: u32) -> bool { + super::ax::frontmost_application_pid() == Some(pid) +} + +fn target_process_exists(pid: u32) -> bool { + i32::try_from(pid).ok().is_some_and(|pid| { + class(c"NSRunningApplication").is_some_and(|application| { + ffi::send_id_i32( + application, + c"runningApplicationWithProcessIdentifier:", + pid, + ) + .is_some() + }) + }) +} + +// This thread-local is intentionally read only by callbacks submitted to the +// process main queue. Objective-C window and layer pointers therefore never +// cross back into background-thread UI code. thread_local! { static MAIN_STATE: RefCell = const { RefCell::new(OverlayState { cursor: None, - border: None, + target_pid: None, + poll_armed: false, }) }; } @@ -190,17 +193,8 @@ unsafe extern "C" fn run_command(context: *mut c_void) { return; }; match *command { - UiCommand::ShowAction { - kind, - point, - window_frame, - } => state.show_action(kind, point, window_frame), - UiCommand::ShowDrag { - from, - to, - window_frame, - } => state.show_drag(from, to, window_frame), - UiCommand::Highlight(window_frame) => state.highlight(window_frame), + UiCommand::ShowAction { pid, kind, point } => state.show_action(pid, kind, point), + UiCommand::ShowDrag { pid, from, to } => state.show_drag(pid, from, to), UiCommand::Clear => state.clear(), } }); @@ -208,3 +202,27 @@ unsafe extern "C" fn run_command(context: *mut c_void) { let _ = send_void(pool, c"drain"); } } + +// SAFETY: libdispatch calls this with the null context supplied when the poll is armed. +unsafe extern "C" fn poll_foreground(_context: *mut c_void) { + let pool = class(c"NSAutoreleasePool").and_then(|pool| send_id(pool, c"new")); + let _ = MAIN_STATE.try_with(|state| { + let Ok(mut state) = state.try_borrow_mut() else { + return; + }; + state.poll_armed = false; + if ENABLED.load(Ordering::Acquire) && state.target_pid.is_some() { + state.refresh_visibility(); + if state.target_pid.is_some() + && ffi::dispatch_main_after(FOREGROUND_POLL_INTERVAL_NS, poll_foreground) + { + state.poll_armed = true; + } + } else { + state.clear(); + } + }); + if let Some(pool) = pool { + let _ = send_void(pool, c"drain"); + } +} diff --git a/crates/core/src/relay.rs b/crates/core/src/relay.rs index 3f9187ca..5d554e6b 100644 --- a/crates/core/src/relay.rs +++ b/crates/core/src/relay.rs @@ -234,6 +234,15 @@ fn render_turn(number: usize, entries: &[&TimelineEntry], timeline: &Timeline) - EntryContent::ContextCompacted => { activity(&mut body, "context", "provider", "compacted") } + EntryContent::ContextWindowChanged { window } => activity( + &mut body, + "context", + "provider", + &format!( + "window set to {}", + agent::claude::format_context_window(*window) + ), + ), EntryContent::ModelChanged { .. } => {} EntryContent::Item(ItemContent::WebSearch { query }) => activity( &mut body, diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index e43d885a..4469f0da 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -429,6 +429,10 @@ pub enum EntryContent { }, /// The provider compacted its context window (a "Context compacted" work-log row). ContextCompacted, + /// The user changed the context window for the next provider turn. + ContextWindowChanged { + window: u64, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -856,6 +860,16 @@ impl Timeline { turn, })); } + AgentEvent::ContextWindowChanged { window } => { + let turn = self.ensure_turn(ts); + let id = self.synthetic_id("context-window"); + self.entries.push(Arc::new(TimelineEntry { + id, + content: EntryContent::ContextWindowChanged { window: *window }, + ts, + turn, + })); + } // Session metadata (composer menus) — not folded into the timeline. // Session metadata (composer menus / traits picker) — held on the // active session, not folded into the timeline. @@ -1415,6 +1429,17 @@ mod tests { )); } + #[test] + fn context_window_change_folds_into_the_timeline() { + let timeline = + Timeline::fold_events([AgentEvent::ContextWindowChanged { window: 500_000 }]); + + assert!(timeline.entries.iter().any(|entry| matches!( + entry.content, + EntryContent::ContextWindowChanged { window: 500_000 } + ))); + } + #[test] fn structured_user_input_blocks_until_resolution_or_turn_end() { let request = AgentEvent::UserInputRequested { diff --git a/crates/runtime/src/app/active_session.rs b/crates/runtime/src/app/active_session.rs index f3623132..67ead6bf 100644 --- a/crates/runtime/src/app/active_session.rs +++ b/crates/runtime/src/app/active_session.rs @@ -37,6 +37,8 @@ pub struct QueuedMessage { /// the timeline can split the prefix from the user's own words; `None` for /// every ordinary send. pub(super) context_len: Option, + /// Context-window selection changed while the provider was live. + pub(super) context_window_changed: Option, /// Orchestration callbacks arriving during the same provider-start window /// are folded into one wake-up turn. Once that turn is live, later callbacks /// are steered into it instead of becoming more queued turns. @@ -98,6 +100,7 @@ impl From<&str> for QueuedMessage { options: TurnOptions::default(), ultrathink: false, context_len: None, + context_window_changed: None, kind: QueuedMessageKind::User, } } @@ -421,6 +424,7 @@ impl ActiveSession { let options = self.turn_options(); let ultrathink = std::mem::take(&mut self.pending_ultrathink); let context_len = std::mem::take(&mut self.pending_context_len); + let context_window_changed = self.context_window_change(); self.queue.push(QueuedMessage { id, text, @@ -430,6 +434,7 @@ impl ActiveSession { options, ultrathink, context_len, + context_window_changed, kind: QueuedMessageKind::User, }); id @@ -449,6 +454,7 @@ impl ActiveSession { let options = self.turn_options(); let ultrathink = std::mem::take(&mut self.pending_ultrathink); let context_len = std::mem::take(&mut self.pending_context_len); + let context_window_changed = self.context_window_change(); self.queue.push(QueuedMessage { id, text, @@ -458,6 +464,7 @@ impl ActiveSession { options, ultrathink, context_len, + context_window_changed, kind: QueuedMessageKind::User, }); id @@ -490,11 +497,22 @@ impl ActiveSession { options, ultrathink: false, context_len: None, + context_window_changed: None, kind: QueuedMessageKind::OrchestrateCallback, }); id } + fn context_window_change(&self) -> Option { + if !matches!(self.runtime, Runtime::Live(_)) { + return None; + } + let model = self.meta.model.as_deref().unwrap_or_default(); + let selected = agent::claude::resolved_context_window(model, &self.meta.option_selections); + let live = agent::claude::resolved_context_window(model, &self.live_option_selections); + (selected != live).then_some(selected) + } + /// Dispatch at most one eligible queued message as an ordinary turn. FIFO /// is preserved among eligible entries, while a future scheduled entry may /// be passed by ordinary work. A turn already in flight blocks dispatch for EVERY provider: a diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 6dfb7cb1..f8b68c6d 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -382,6 +382,7 @@ impl AppState { | AgentEvent::UserInputResolved { .. } | AgentEvent::TokenUsage(_) | AgentEvent::ContextCompacted + | AgentEvent::ContextWindowChanged { .. } | AgentEvent::PlanUpdated { .. } | AgentEvent::ProposedPlanDelta { .. } | AgentEvent::ProposedPlan { .. } diff --git a/crates/runtime/src/app/send.rs b/crates/runtime/src/app/send.rs index 4e08afae..fa23c84c 100644 --- a/crates/runtime/src/app/send.rs +++ b/crates/runtime/src/app/send.rs @@ -418,6 +418,9 @@ impl AppState { &message.attachments, cx, ); + if let Some(window) = message.context_window_changed { + self.record_event(session_id, &AgentEvent::ContextWindowChanged { window }, cx); + } if is_active { // `/orchestrate` stores its provider-only guidance as a prefix and // records the byte boundary in `context_len`. Titles should describe diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index b1e04f16..2558b034 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -3763,6 +3763,27 @@ fn opencode_effort_is_applied_per_turn_without_restart() { assert!(!active.options_changed_while_live()); } +#[test] +fn queued_message_stamps_live_context_window_change() { + let mut active = live_session(ProviderKind::ClaudeCode, smol::channel::unbounded().0); + active.meta.model = Some("claude-opus-5".into()); + active.live_option_selections.push(OptionSelection { + id: "contextWindow".into(), + value: serde_json::json!("1m"), + }); + active.meta.option_selections.push(OptionSelection { + id: "contextWindow".into(), + value: serde_json::json!(500_000), + }); + + active.push_queued("queued".into(), Vec::new()); + assert_eq!(active.queue[0].context_window_changed, Some(500_000)); + + active.runtime = Runtime::Idle; + active.push_scheduled("idle".into(), Vec::new(), SystemTime::now()); + assert_eq!(active.queue[1].context_window_changed, None); +} + #[test] fn native_rewind_waits_for_provider_confirmation_before_pruning() { let cx = &mut TestAppContext::default(); diff --git a/crates/ui/src/chat/components/dividers.rs b/crates/ui/src/chat/components/dividers.rs index 56d03404..8020dd33 100644 --- a/crates/ui/src/chat/components/dividers.rs +++ b/crates/ui/src/chat/components/dividers.rs @@ -93,3 +93,16 @@ pub(crate) fn context_compacted_divider(id: &str, cx: &App) -> AnyElement { cx, ) } + +pub(crate) fn context_window_changed_divider(id: &str, window: u64, cx: &App) -> AnyElement { + divider( + SharedString::from(format!("context-window-changed-{id}")), + crate::tr!( + "chat.context_window_changed", + window = agent::claude::format_context_window(window) + ) + .into_owned(), + cx.theme().muted_foreground, + cx, + ) +} diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 3c401699..c373312c 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -891,6 +891,14 @@ impl ChatView { &entry.id, cx, )); } + Segment::ContextWindowChanged(entry) => { + let EntryContent::ContextWindowChanged { window } = entry.content else { + unreachable!(); + }; + column = column.child(components::dividers::context_window_changed_divider( + &entry.id, window, cx, + )); + } Segment::ActivityRun(activities) => { let segment_id = activities[0].id.as_str(); column = column.child(self.compose_work_log( diff --git a/crates/ui/src/chat/model.rs b/crates/ui/src/chat/model.rs index a5e3b0d2..e70498c1 100644 --- a/crates/ui/src/chat/model.rs +++ b/crates/ui/src/chat/model.rs @@ -27,6 +27,7 @@ pub(crate) enum Segment<'a> { Relay(&'a TimelineEntry), ModelChange(&'a TimelineEntry), ContextCompacted(&'a TimelineEntry), + ContextWindowChanged(&'a TimelineEntry), User(&'a TimelineEntry), Assistant(&'a TimelineEntry), Error(&'a TimelineEntry), @@ -162,6 +163,10 @@ pub(crate) fn segment_entries<'a>( flush_activities(&mut segments, &mut activities); segments.push(Segment::ContextCompacted(entry)); } + EntryContent::ContextWindowChanged { .. } => { + flush_activities(&mut segments, &mut activities); + segments.push(Segment::ContextWindowChanged(entry)); + } EntryContent::Item(ItemContent::AssistantMessage { .. }) => { flush_activities(&mut segments, &mut activities); segments.push(Segment::Assistant(entry)); @@ -215,6 +220,7 @@ pub(crate) fn work_log_counts(entries: &[&TimelineEntry]) -> WorkLogCounts { | EntryContent::Item(ItemContent::Other { .. }) => counts.tools += 1, EntryContent::Item(ItemContent::Subagent { .. }) => counts.subagents += 1, EntryContent::ContextCompacted + | EntryContent::ContextWindowChanged { .. } | EntryContent::Steer { .. } | EntryContent::Item(ItemContent::UserMessage { .. }) | EntryContent::Item(ItemContent::AssistantMessage { .. }) @@ -1094,6 +1100,7 @@ fn hash_entry_shape(content: &EntryContent, hash: &mut DefaultHasher) { reason.hash(hash); } EntryContent::ContextCompacted => {} + EntryContent::ContextWindowChanged { window } => window.hash(hash), EntryContent::Item(ItemContent::WebSearch { query }) => { "web_search".len().hash(hash); serde_json::json!({ "query": query }) @@ -1587,6 +1594,24 @@ mod tests { assert!(matches!(segments[5], Segment::Error(entry) if entry.id == "error")); } + #[test] + fn segment_entries_flushes_activities_before_context_window_changes() { + let entries = [ + command("cmd"), + entry( + "window", + EntryContent::ContextWindowChanged { window: 500_000 }, + ), + ]; + let segments = segment_entries(&entries, false).flow; + + assert!(matches!( + segments.as_slice(), + [Segment::ActivityRun(activities), Segment::ContextWindowChanged(entry)] + if activities.len() == 1 && entry.id == "window" + )); + } + #[test] fn segment_entries_coalesces_an_all_activity_turn() { let entries = [command("cmd-1"), command("cmd-2")]; diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 58f27415..6cfa3a14 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -300,10 +300,18 @@ impl Composer { ); let store_entity = self.workspace_store.clone(); + let composer_entity = cx.entity(); + let context_window_custom = self.context_window_custom.clone(); crate::material::overlay_popover("traits-popover") .anchor(Anchor::BottomLeft) .trigger(trigger) .content(move |_, _, cx| { + let popover = cx.entity(); + composer_entity.update(cx, |composer, _cx| { + composer.traits_popover = Some(popover.clone()); + }); + let context_window_custom_error = + composer_entity.read(cx).context_window_custom_error; render_traits_pane( &spec, &selections, @@ -311,7 +319,9 @@ impl Composer { locked, pending_restart, &store_entity, - &cx.entity(), + &context_window_custom, + context_window_custom_error, + &popover, cx, ) }) @@ -971,6 +981,8 @@ fn render_traits_pane( locked: bool, pending_restart: bool, store_entity: &Entity, + context_window_custom: &Entity, + context_window_custom_error: bool, popover: &Entity, cx: &mut Context, ) -> AnyElement { @@ -1002,6 +1014,7 @@ fn render_traits_pane( default_value, } => { let is_reasoning = id == "reasoningEffort"; + let is_context_window = id == "contextWindow"; pane = pane.child(section_header(label, cx)); if is_reasoning && locked { pane = pane.child( @@ -1015,11 +1028,18 @@ fn render_traits_pane( ); continue; } - let resolved = resolved_select_value(id, options, default_value, selections); + let resolved = (!is_context_window) + .then(|| resolved_select_value(id, options, default_value, selections)) + .flatten(); + let resolved_window = is_context_window + .then(|| agent::claude::resolved_context_window(&spec.id, selections)); for (index, opt) in options.iter().enumerate() { let is_default = default_value.as_deref() == Some(opt.value.as_str()); let is_ultra = is_reasoning && opt.value == "ultrathink"; - let is_selected = if is_reasoning && ultrathink_armed { + let is_selected = if let Some(resolved_window) = resolved_window { + agent::claude::parse_context_window_tokens(&serde_json::json!(opt.value)) + == Some(resolved_window) + } else if is_reasoning && ultrathink_armed { is_ultra } else if is_ultra { false @@ -1068,6 +1088,60 @@ fn render_traits_pane( }), ); } + if let Some(resolved_window) = resolved_window { + let preset_selected = options.iter().any(|opt| { + agent::claude::parse_context_window_tokens(&serde_json::json!(opt.value)) + == Some(resolved_window) + }); + let custom_selected = !preset_selected; + let mut label = crate::tr!("composer.context_window_custom").into_owned(); + if custom_selected { + label.push_str(&format!( + " ({})", + agent::claude::format_context_window(resolved_window) + )); + } + let input = context_window_custom.clone(); + pane = pane + .child( + h_flex() + .id("trait-opt-context-window-custom") + .flex_none() + .w_full() + .px_2() + .py_1p5() + .gap_2() + .items_center() + .rounded(px(6.)) + .cursor_pointer() + .text_size(px(13.)) + .hover(|s| s.bg(cx.theme().muted)) + .child(div().flex_1().min_w_0().child(label)) + .when(custom_selected, |this| { + this.child( + Icon::new(IconName::Check).xsmall().text_color(primary), + ) + }) + .on_click(move |_, window, cx| { + input.update(cx, |state, cx| state.focus(window, cx)); + }), + ) + .child( + v_flex() + .px_2() + .pb_1() + .gap_1() + .child(Input::new(context_window_custom).appearance(false)) + .when(context_window_custom_error, |this| { + this.child( + div() + .text_size(px(11.)) + .text_color(cx.theme().danger) + .child(crate::tr!("composer.context_window_invalid")), + ) + }), + ); + } } OptionDescriptor::Boolean { id, diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 6873a517..ace09a77 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -109,6 +109,9 @@ pub struct Composer { /// Unsent text is isolated by persisted thread or project New thread page. text_cache: ComposerTextCache, model_search: Entity, + context_window_custom: Entity, + context_window_custom_error: bool, + traits_popover: Option>, /// `None` = follow the active session's provider (set on first open). picker_rail: Option, /// Whether the approval panel's detail is expanded. @@ -185,6 +188,10 @@ impl Composer { let model_search = cx.new(|cx| { InputState::new(window, cx).placeholder(crate::tr!("composer.search_models")) }); + let context_window_custom = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::tr!("composer.context_window_custom_placeholder")) + }); let user_input_custom = cx.new(|cx| { TextareaState::new(window, cx) .rows(1) @@ -268,6 +275,38 @@ impl Composer { cx.notify(); } }), + cx.subscribe_in( + &context_window_custom, + window, + |this, input, event, window, cx| match event { + InputEvent::PressEnter { .. } => { + let value = input.read(cx).value().to_string(); + if let Some(tokens) = agent::claude::parse_context_window_tokens( + &serde_json::Value::String(value), + ) { + this.workspace_store.update(cx, |store, _cx| { + store.set_active_option( + "contextWindow".to_string(), + Some(serde_json::json!(tokens)), + ); + }); + this.context_window_custom_error = false; + input.update(cx, |state, cx| state.set_value("", window, cx)); + if let Some(popover) = this.traits_popover.clone() { + popover.update(cx, |state, cx| state.dismiss(window, cx)); + } + } else { + this.context_window_custom_error = true; + cx.notify(); + } + } + InputEvent::Change => { + this.context_window_custom_error = false; + cx.notify(); + } + _ => {} + }, + ), ]; Self { @@ -278,6 +317,9 @@ impl Composer { fallback_review_seeded: None, text_cache: ComposerTextCache::default(), model_search, + context_window_custom, + context_window_custom_error: false, + traits_popover: None, picker_rail: None, approval_expanded: false, ui_request_id: None, diff --git a/crates/ui/src/composer/model.rs b/crates/ui/src/composer/model.rs index a13033e7..c24df8b7 100644 --- a/crates/ui/src/composer/model.rs +++ b/crates/ui/src/composer/model.rs @@ -369,6 +369,12 @@ pub(super) fn traits_chip_label( parts.push(o.label.clone()); continue; } + if id == "contextWindow" { + parts.push(agent::claude::format_context_window( + agent::claude::resolved_context_window(&spec.id, selections), + )); + continue; + } let part = resolved_select_value(id, options, default_value, selections) .and_then(|value| options.iter().find(|o| o.value == value)) .map(|option| option.label.clone()) @@ -865,10 +871,11 @@ mod tests { }, ], }; - // Defaults resolve to "High · 200k". + // Context windows resolve from the model's native default, not the + // descriptor's stale fallback. assert_eq!( traits_chip_label(&spec, &[], false), - Some("High · 200k".into()) + Some("High · 1M".into()) ); // A selection overrides the default. let sel = vec![agent::OptionSelection { @@ -879,6 +886,14 @@ mod tests { traits_chip_label(&spec, &sel, false), Some("High · 1M".into()) ); + let custom = vec![agent::OptionSelection { + id: "contextWindow".into(), + value: serde_json::json!(500_000), + }]; + assert_eq!( + traits_chip_label(&spec, &custom, false), + Some("High · 500k".into()) + ); // Fast Mode boolean → Fast/Normal; a plain boolean → "