feat(quick-pane): add constrained agent window

This commit is contained in:
2026-08-25 18:30:39 +02:00
parent 9f2c840ae7
commit 4f57114f19
16 changed files with 760 additions and 9 deletions
+80
View File
@@ -37,6 +37,34 @@ pub struct LastSeenState {
pub last_activity: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct QuickPaneSettings {
pub workspace: String,
pub provider: String,
pub model: String,
pub follow_up_provider: String,
pub follow_up_model: String,
pub instructions: String,
pub maximum_seconds: u32,
pub maximum_tool_calls: u32,
}
impl Default for QuickPaneSettings {
fn default() -> Self {
Self {
workspace: String::new(),
provider: String::new(),
model: String::new(),
follow_up_provider: String::new(),
follow_up_model: String::new(),
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
maximum_seconds: 60,
maximum_tool_calls: 6,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiStateV1 {
@@ -61,6 +89,8 @@ pub struct UiStateV1 {
pub default_new_session_worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>,
#[serde(default)]
pub quick_pane: QuickPaneSettings,
}
impl Default for UiStateV1 {
@@ -77,6 +107,7 @@ impl Default for UiStateV1 {
interface_scale: None,
default_new_session_worktree: None,
dismissed_collision_warning: None,
quick_pane: QuickPaneSettings::default(),
}
}
}
@@ -182,6 +213,39 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
let quick = &state.quick_pane;
let quick_is_default = quick.workspace.is_empty()
&& quick.provider.is_empty()
&& quick.model.is_empty()
&& quick.follow_up_provider.is_empty()
&& quick.follow_up_model.is_empty();
if !quick_is_default {
bounded(&quick.workspace, "quick-pane workspace")?;
if !Path::new(&quick.workspace).is_absolute() || !Path::new(&quick.workspace).is_dir() {
return Err("Quick-pane workspace must be an existing absolute directory".to_owned());
}
bounded(&quick.provider, "quick-pane provider")?;
bounded(&quick.model, "quick-pane model")?;
bounded(&quick.follow_up_provider, "quick-pane follow-up provider")?;
bounded(&quick.follow_up_model, "quick-pane follow-up model")?;
}
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
}
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
{
return Err("Quick-pane limits are outside the supported range".to_owned());
}
total_string_bytes = total_string_bytes
.saturating_add(quick.workspace.len())
.saturating_add(quick.provider.len())
.saturating_add(quick.model.len())
.saturating_add(quick.follow_up_provider.len())
.saturating_add(quick.follow_up_model.len())
.saturating_add(quick.instructions.len());
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
if let Some(scale) = state.interface_scale {
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
return Err("Interface scale is outside the supported range".to_owned());
@@ -368,6 +432,22 @@ mod tests {
.collect()
}
#[test]
fn quick_pane_settings_validate_complete_existing_workspace() {
let root = temp_dir("quick-pane");
fs::create_dir_all(&root).unwrap();
let mut state = UiStateV1::default();
state.quick_pane.workspace = root.to_string_lossy().into_owned();
state.quick_pane.provider = "provider".into();
state.quick_pane.model = "quick-model".into();
state.quick_pane.follow_up_provider = "provider".into();
state.quick_pane.follow_up_model = "follow-up-model".into();
assert!(validate(&state).is_ok());
state.quick_pane.maximum_seconds = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace");