feat(quick-pane): add constrained agent window
This commit is contained in:
@@ -214,6 +214,17 @@ pub async fn create_session_runtime(
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn create_quick_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
) -> Result<RuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"create_quick_runtime",
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn open_session_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
@@ -237,6 +248,17 @@ pub async fn close_session_runtime(
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn close_quick_runtime(
|
||||
socket_path: &str,
|
||||
runtime_id: &str,
|
||||
) -> Result<CloseRuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"close_quick_runtime",
|
||||
Some(json!({ "runtimeId": runtime_id })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn list_directory_sessions(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
|
||||
+42
-1
@@ -45,6 +45,21 @@ fn socket_path() -> Result<String, String> {
|
||||
bridge::default_socket_path()
|
||||
}
|
||||
|
||||
fn requested_quick_pane(args: &[String]) -> bool {
|
||||
args.iter().any(|arg| arg == "--quick")
|
||||
}
|
||||
|
||||
fn show_quick_pane(app: &AppHandle) {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.hide();
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("quick-pane") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
|
||||
args.iter()
|
||||
.position(|arg| arg == "--worktree")
|
||||
@@ -112,6 +127,11 @@ async fn create_session_runtime(worktree_path: String) -> Result<bridge::Runtime
|
||||
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_session_runtime(
|
||||
worktree_path: String,
|
||||
@@ -125,6 +145,11 @@ async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntim
|
||||
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn close_quick_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
|
||||
bridge::close_quick_runtime(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_directory_sessions(
|
||||
worktree_path: String,
|
||||
@@ -352,6 +377,10 @@ pub fn run() {
|
||||
task: Mutex::new(None),
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if requested_quick_pane(&args) {
|
||||
show_quick_pane(app);
|
||||
return;
|
||||
}
|
||||
launch_new_session(app.clone(), &args);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||
@@ -366,15 +395,21 @@ pub fn run() {
|
||||
}
|
||||
}))
|
||||
.setup(move |app| {
|
||||
launch_new_session(app.handle().clone(), &launch_args);
|
||||
if requested_quick_pane(&launch_args) {
|
||||
show_quick_pane(app.handle());
|
||||
} else {
|
||||
launch_new_session(app.handle().clone(), &launch_args);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_workspace,
|
||||
get_workspace_summary,
|
||||
create_session_runtime,
|
||||
create_quick_runtime,
|
||||
open_session_runtime,
|
||||
close_session_runtime,
|
||||
close_quick_runtime,
|
||||
list_directory_sessions,
|
||||
get_session_runtime_snapshot,
|
||||
load_ui_state,
|
||||
@@ -434,6 +469,12 @@ mod tests {
|
||||
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_flag_is_detected_without_affecting_toggle() {
|
||||
assert!(requested_quick_pane(&["--quick".to_owned()]));
|
||||
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||
let toggle = vec!["--toggle".to_owned()];
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user