diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 379e79c3..fbac5db0 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -708,8 +708,8 @@ pub fn configure_goose_mode_dialog() -> Result<(), Box> { "All tools, extensions and file modificatio will require human approval" ) .item( - "write_approve", - "Write Approve Mode", + "smart_approve", + "Smart Approve Mode", "Editing, creating, deleting files and using extensions will require human approval" ) .item( @@ -728,9 +728,9 @@ pub fn configure_goose_mode_dialog() -> Result<(), Box> { config.set_param("GOOSE_MODE", Value::String("approve".to_string()))?; cliclack::outro("Set to Approve Mode - all tools and modifications require approval")?; } - "write_approve" => { - config.set_param("GOOSE_MODE", Value::String("write_approve".to_string()))?; - cliclack::outro("Set to Write Approve Mode - modifications require approval")?; + "smart_approve" => { + config.set_param("GOOSE_MODE", Value::String("smart_approve".to_string()))?; + cliclack::outro("Set to Smart Approve Mode - modifications require approval")?; } "chat" => { config.set_param("GOOSE_MODE", Value::String("chat".to_string()))?; diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index ad3a0fbe..38e428be 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -77,7 +77,7 @@ impl GooseCompleter { /// Complete flags for the /mode command fn complete_mode_flags(&self, line: &str) -> Result<(usize, Vec)> { - let modes = ["auto", "approve", "write_approve", "chat"]; + let modes = ["auto", "approve", "smart_approve", "chat"]; let parts: Vec<&str> = line.split_whitespace().collect(); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 694b8f55..ad41061f 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -335,7 +335,7 @@ impl Session { let mode = mode.to_lowercase(); // Check if mode is valid - if !["auto", "approve", "chat", "write_approve"].contains(&mode.as_str()) { + if !["auto", "approve", "chat", "smart_approve"].contains(&mode.as_str()) { output::render_error(&format!( "Invalid mode '{}'. Mode must be one of: auto, approve, chat", mode diff --git a/crates/goose/src/agents/truncate.rs b/crates/goose/src/agents/truncate.rs index 34915ac4..a20212e4 100644 --- a/crates/goose/src/agents/truncate.rs +++ b/crates/goose/src/agents/truncate.rs @@ -296,7 +296,7 @@ impl Agent for TruncateAgent { // Clone goose_mode once before the match to avoid move issues let mode = goose_mode.clone(); match mode.as_str() { - "approve" | "write_approve" => { + "approve" | "smart_approve" => { let mut read_only_tools = Vec::new(); let mut needs_confirmation = Vec::<&ToolRequest>::new(); let mut approved_tools = Vec::new(); @@ -319,7 +319,7 @@ impl Agent for TruncateAgent { } // Only check read-only status for tools needing confirmation - if !needs_confirmation.is_empty() && mode == "write_approve" { + if !needs_confirmation.is_empty() && mode == "smart_approve" { read_only_tools = detect_read_only_tools(&capabilities, needs_confirmation.clone()).await; } diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/BottomMenu.tsx index 30ece268..57e3ee58 100644 --- a/ui/desktop/src/components/BottomMenu.tsx +++ b/ui/desktop/src/components/BottomMenu.tsx @@ -5,7 +5,6 @@ import { Sliders } from 'lucide-react'; import { ModelRadioList } from './settings/models/ModelRadioList'; import { Document, ChevronUp, ChevronDown } from './icons'; import type { View } from '../App'; -import { getApiUrl, getSecretKey } from '../config'; import { BottomMenuModeSelection } from './BottomMenuModeSelection'; export default function BottomMenu({ @@ -20,10 +19,6 @@ export default function BottomMenu({ const { recentModels } = useRecentModels(); // Get recent models const dropdownRef = useRef(null); - const [isGooseModeMenuOpen, setIsGooseModeMenuOpen] = useState(false); - const [gooseMode, setGooseMode] = useState('auto'); - const gooseModeDropdownRef = useRef(null); - // Add effect to handle clicks outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -41,31 +36,6 @@ export default function BottomMenu({ }; }, [isModelMenuOpen]); - useEffect(() => { - const fetchCurrentMode = async () => { - try { - const response = await fetch(getApiUrl('/configs/get?key=GOOSE_MODE'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - }); - - if (response.ok) { - const { value } = await response.json(); - if (value) { - setGooseMode(value); - } - } - } catch (error) { - console.error('Error fetching current mode:', error); - } - }; - - fetchCurrentMode(); - }, []); - // Add effect to handle Escape key useEffect(() => { const handleEsc = (event: KeyboardEvent) => { @@ -83,41 +53,6 @@ export default function BottomMenu({ }; }, [isModelMenuOpen]); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - gooseModeDropdownRef.current && - !gooseModeDropdownRef.current.contains(event.target as Node) - ) { - setIsGooseModeMenuOpen(false); - } - }; - - if (isGooseModeMenuOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isGooseModeMenuOpen]); - - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsGooseModeMenuOpen(false); - } - }; - - if (isGooseModeMenuOpen) { - window.addEventListener('keydown', handleEsc); - } - - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, [isGooseModeMenuOpen]); - // Removed the envModelProvider code that was checking for environment variables return ( @@ -139,24 +74,7 @@ export default function BottomMenu({ {/* Goose Mode Selector Dropdown */} -
-
setIsGooseModeMenuOpen(!isGooseModeMenuOpen)} - > - Goose Mode: {gooseMode} - {isGooseModeMenuOpen ? ( - - ) : ( - - )} -
- - {/* Dropdown Menu */} - {isGooseModeMenuOpen && ( - - )} -
+ {/* Model Selector Dropdown - Only in development */}
diff --git a/ui/desktop/src/components/BottomMenuModeSelection.tsx b/ui/desktop/src/components/BottomMenuModeSelection.tsx index c442e2b8..7e3557ac 100644 --- a/ui/desktop/src/components/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/BottomMenuModeSelection.tsx @@ -1,20 +1,82 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { getApiUrl, getSecretKey } from '../config'; +import { ChevronDown, ChevronUp } from './icons'; +import { + all_goose_modes, + filterGooseModes, + ModeSelectionItem, +} from './settings/basic/ModeSelectionItem'; -export const BottomMenuModeSelection = ({ selectedMode, setSelectedMode }) => { - const modes = [ - { - value: 'auto', - }, - { - value: 'approve', - }, - { - value: 'chat', - }, - ]; +export const BottomMenuModeSelection = () => { + const [isGooseModeMenuOpen, setIsGooseModeMenuOpen] = useState(false); + const [gooseMode, setGooseMode] = useState('auto'); + const [previousApproveModel, setPreviousApproveModel] = useState(''); + const gooseModeDropdownRef = useRef(null); + + useEffect(() => { + const fetchCurrentMode = async () => { + try { + const response = await fetch(getApiUrl('/configs/get?key=GOOSE_MODE'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + }); + + if (response.ok) { + const { value } = await response.json(); + if (value) { + setGooseMode(value); + } + } + } catch (error) { + console.error('Error fetching current mode:', error); + } + }; + + fetchCurrentMode(); + }, []); + + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsGooseModeMenuOpen(false); + } + }; + + if (isGooseModeMenuOpen) { + window.addEventListener('keydown', handleEsc); + } + + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [isGooseModeMenuOpen]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + gooseModeDropdownRef.current && + !gooseModeDropdownRef.current.contains(event.target as Node) + ) { + setIsGooseModeMenuOpen(false); + } + }; + + if (isGooseModeMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isGooseModeMenuOpen]); const handleModeChange = async (newMode: string) => { + if (gooseMode === newMode) { + return; + } const storeResponse = await fetch(getApiUrl('/configs/store'), { method: 'POST', headers: { @@ -33,41 +95,48 @@ export const BottomMenuModeSelection = ({ selectedMode, setSelectedMode }) => { console.error('Store response error:', errorText); throw new Error(`Failed to store new goose mode: ${newMode}`); } - setSelectedMode(newMode); + if (gooseMode.includes('approve')) { + setPreviousApproveModel(gooseMode); + } + setGooseMode(newMode); }; + function getValueByKey(key) { + const mode = all_goose_modes.find((mode) => mode.key === key); + return mode ? mode.label : 'auto'; + } + return ( -
-
- {modes.map((mode) => ( - - ))} +
+
setIsGooseModeMenuOpen(!isGooseModeMenuOpen)} + > + Goose Mode: {getValueByKey(gooseMode)} + {isGooseModeMenuOpen ? ( + + ) : ( + + )}
+ + {/* Dropdown Menu */} + {isGooseModeMenuOpen && ( +
+
+ {filterGooseModes(gooseMode, all_goose_modes, previousApproveModel).map((mode) => ( + + ))} +
+
+ )}
); }; diff --git a/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx b/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx new file mode 100644 index 00000000..85e7eee7 --- /dev/null +++ b/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { Card } from '../../ui/card'; +import { Button } from '../../ui/button'; +import { GooseMode, ModeSelectionItem } from './ModeSelectionItem'; + +interface ConfigureApproveModeProps { + onClose: () => void; + handleModeChange: (newMode: string) => void; + currentMode: string | null; +} + +export function ConfigureApproveMode({ + onClose, + handleModeChange, + currentMode, +}: ConfigureApproveModeProps) { + const approveModes: GooseMode[] = [ + { + key: 'approve', + label: 'Manual Approval', + description: 'All tools, extensions and file modificatio will require human approval', + }, + { + key: 'smart_approve', + label: 'Smart Approval', + description: 'Intelligently determime which actions need approval based on risk level ', + }, + ]; + + const [isSubmitting, setIsSubmitting] = useState(false); + const [approveMode, setApproveMode] = useState(currentMode); + + useEffect(() => { + setApproveMode(currentMode); + }, [currentMode]); + + const handleModeSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setIsSubmitting(true); + try { + handleModeChange(approveMode); + onClose(); + } catch (error) { + console.error('Error configuring goose mode:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+ {/* Header */} +
+

+ Configure Approve Mode +

+
+ +
+

+ Approve requests can either be given to all tool requests or determine which actions + may need integration +

+
+ {approveModes.map((mode) => ( + { + setApproveMode(newMode); + }} + /> + ))} +
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/ui/desktop/src/components/settings/basic/ModeSelection.tsx b/ui/desktop/src/components/settings/basic/ModeSelection.tsx index 76aaba99..d2722448 100644 --- a/ui/desktop/src/components/settings/basic/ModeSelection.tsx +++ b/ui/desktop/src/components/settings/basic/ModeSelection.tsx @@ -1,28 +1,10 @@ -import * as RadioGroup from '@radix-ui/react-radio-group'; import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from '../../../config'; +import { all_goose_modes, filterGooseModes, ModeSelectionItem } from './ModeSelectionItem'; export const ModeSelection = () => { - const modes = [ - { - value: 'auto', - label: 'Completely autonomous', - description: 'Full file modification capabilities, edit, create, and delete files freely.', - }, - { - value: 'approve', - label: 'Approval needed', - description: - 'Classifies the tool as either a read-only tool or write tool. Write tools will ask for human approval.', - }, - { - value: 'chat', - label: 'Chat only', - description: 'Engage with the selected provider without using tools or extensions.', - }, - ]; - const [currentMode, setCurrentMode] = useState('auto'); + const [previousApproveModel, setPreviousApproveModel] = useState(''); const handleModeChange = async (newMode: string) => { const storeResponse = await fetch(getApiUrl('/configs/store'), { @@ -43,6 +25,10 @@ export const ModeSelection = () => { console.error('Store response error:', errorText); throw new Error(`Failed to store new goose mode: ${newMode}`); } + // Only track the previous approve if current mode is approve related but new mode is not. + if (currentMode.includes('approve') && !newMode.includes('approve')) { + setPreviousApproveModel(currentMode); + } setCurrentMode(newMode); }; @@ -73,37 +59,24 @@ export const ModeSelection = () => { return (
-

Mode Selection

+

Mode Selection

+

+ Change the access goose is given to modify, edit or delet files. This setting can be changed + at anytime. +

- - {modes.map((mode) => ( - -
-

- {mode.label} -

-

- {mode.description} -

-
-
-
- {currentMode === mode.value && ( -
- )} -
-
- +
+ {filterGooseModes(currentMode, all_goose_modes, previousApproveModel).map((mode) => ( + ))} - +
); }; diff --git a/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx b/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx new file mode 100644 index 00000000..c6dc890f --- /dev/null +++ b/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { Gear } from '../../icons'; +import { ConfigureApproveMode } from './ConfigureApproveMode'; + +export interface GooseMode { + key: string; + label: string; + description: string; +} + +export const all_goose_modes: GooseMode[] = [ + { + key: 'auto', + label: 'Completely Autonomous', + description: 'Full file modification capabilities, edit, create, and delete files freely.', + }, + { + key: 'approve', + label: 'Manual Approval', + description: 'All tools, extensions and file modificatio will require human approval', + }, + { + key: 'smart_approve', + label: 'Smart Approval', + description: 'Intelligently determime which actions need approval based on risk level ', + }, + { + key: 'chat', + label: 'Chat Only', + description: 'Engage with the selected provider without using tools or extensions.', + }, +]; + +export function filterGooseModes( + currentMode: string, + modes: GooseMode[], + previousApproveMode: string +) { + return modes.filter((mode) => { + const approveList = ['approve', 'smart_approve']; + const nonApproveList = ['auto', 'chat']; + // Always keep 'auto' and 'chat' + if (nonApproveList.includes(mode.key)) { + return true; + } + // If current mode is non approve mode, we display write approve by default. + if (nonApproveList.includes(currentMode) && !previousApproveMode) { + return mode.key === 'smart_approve'; + } + + // Always include the current and previou approve mode + if (mode.key === currentMode) { + return true; + } + + // Current mode and previous approve mode cannot exist at the same time. + if (approveList.includes(currentMode) && approveList.includes(previousApproveMode)) { + return false; + } + + if (mode.key === previousApproveMode) { + return true; + } + + return false; + }); +} + +interface ModeSelectionItemProps { + currentMode: string; + mode: GooseMode; + showDescription: boolean; + isApproveModeConfigure: boolean; + handleModeChange: (newMode: string) => void; +} + +export function ModeSelectionItem({ + currentMode, + mode, + showDescription, + isApproveModeConfigure, + handleModeChange, +}: ModeSelectionItemProps) { + const [checked, setChecked] = useState(currentMode == mode.key); + const [isDislogOpen, setIsDislogOpen] = useState(false); + + useEffect(() => { + setChecked(currentMode === mode.key); + }, [currentMode, mode.key]); + + return ( +
+
handleModeChange(mode.key)} + > +
+

+ {mode.label} +

+ {showDescription && ( +

+ {mode.description} +

+ )} +
+
+ {!isApproveModeConfigure && (mode.key == 'approve' || mode.key == 'smart_approve') && ( + + )} + handleModeChange(mode.key)} + className="peer sr-only" + /> +
+
+
+
+
+ {isDislogOpen ? ( + { + setIsDislogOpen(false); + }} + handleModeChange={handleModeChange} + currentMode={currentMode} + /> + ) : null} +
+
+
+ ); +}