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/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 → "