diff --git a/crates/mint-core/src/config.rs b/crates/mint-core/src/config.rs index 3be42f7..6d187d8 100644 --- a/crates/mint-core/src/config.rs +++ b/crates/mint-core/src/config.rs @@ -290,7 +290,7 @@ fn runtime_extra_defaults() -> BTreeMap { "notionDatabaseId": "", "notionPageId": "", "notionTitleProperty": "Name", - "allowedShellModes": ["readOnly", "test"], + "allowedShellModes": ["readOnly", "test", "mutating", "network"], "allowedNativePlugins": ["dev_tools", "system_metrics"], "allowedMcpTools": {}, "mcpServers": {} @@ -357,7 +357,7 @@ mod tests { assert_eq!(config.extra["lineWebhookPort"], 3000); assert_eq!( config.extra["allowedShellModes"], - serde_json::json!(["readOnly", "test"]) + serde_json::json!(["readOnly", "test", "mutating", "network"]) ); assert_eq!( config.extra["allowedNativePlugins"], diff --git a/crates/mint-core/src/orchestration.rs b/crates/mint-core/src/orchestration.rs index 2586516..a73ad06 100644 --- a/crates/mint-core/src/orchestration.rs +++ b/crates/mint-core/src/orchestration.rs @@ -192,7 +192,7 @@ fn request_chat_id(request: &ChatRequest) -> &str { .unwrap_or(DEFAULT_CONVERSATION_ID) } -const MAX_STEPS: usize = 16; +const MAX_STEPS: usize = 32; const MAX_OBSERVATION_BYTES: usize = 16_000; pub fn build_system_prompt(config: &MintConfig) -> String { let mut allowed_actions = vec![ @@ -1680,6 +1680,14 @@ fn parse_agent_json(raw: &str) -> Result Result { let value: Value = serde_json::from_str(raw)?; let finish = value.get("finish").cloned().unwrap_or(Value::Null); + let input = match finish { + Value::Object(_) => serde_json::from_value(finish)?, + Value::String(s) => AgentInput { + summary: s, + ..AgentInput::default() + }, + _ => AgentInput::default(), + }; Ok(AgentDecision { thought: value .get("thought") @@ -1687,7 +1695,7 @@ fn parse_shorthand_finish(raw: &str) -> Result .unwrap_or_default() .into(), action: "finish".into(), - input: serde_json::from_value(finish)?, + input, }) } @@ -2006,6 +2014,27 @@ mod tests { assert!(decision.input.summary.is_empty()); } + #[test] + fn shorthand_finish_allows_null_or_missing_finish() { + let decision = parse_decision(r#"{"thought":"done","finish":null}"#) + .expect("null finish should parse"); + assert_eq!(decision.action, "finish"); + assert!(decision.input.summary.is_empty()); + + let decision = parse_decision(r#"{"thought":"done"}"#) + .expect("missing finish should parse"); + assert_eq!(decision.action, "finish"); + assert!(decision.input.summary.is_empty()); + } + + #[test] + fn shorthand_finish_allows_string_finish_as_summary() { + let decision = parse_decision(r#"{"thought":"done","finish":"all done!"}"#) + .expect("string finish should parse as summary"); + assert_eq!(decision.action, "finish"); + assert_eq!(decision.input.summary, "all done!"); + } + #[test] fn write_file_policy_rejects_existing_workspace_file() { let root = diff --git a/crates/mint-core/src/safety.rs b/crates/mint-core/src/safety.rs index 2b3026d..a613976 100644 --- a/crates/mint-core/src/safety.rs +++ b/crates/mint-core/src/safety.rs @@ -384,10 +384,15 @@ mod tests { #[test] fn shell_mode_policy_reads_config_allowlist() { - let config = MintConfig::default(); + let mut config = MintConfig::default(); + config.extra.insert( + "allowedShellModes".to_string(), + serde_json::json!(["readOnly", "test"]), + ); assert!(shell_mode_allowed(&config, ShellCommandMode::ReadOnly)); assert!(shell_mode_allowed(&config, ShellCommandMode::Test)); assert!(!shell_mode_allowed(&config, ShellCommandMode::Network)); + assert!(!shell_mode_allowed(&config, ShellCommandMode::Mutating)); } #[test] diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index 68f6d22..963fc64 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -10,7 +10,7 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::browser::{click, navigate, read_page_text}; -const MAX_STEPS: usize = 10; +const MAX_STEPS: usize = 20; const SYSTEM_PROMPT: &str = r#"You are Mint's native background task agent. Return only JSON: {"thought":"short progress note","action":"done|propose_folder|propose_write_file|open_url|browser_read|browser_click|knowledge_search|propose_bash","target":"path, URL, selector, query, command, or final result","data":"optional file content"} Use only one action per response. Background tasks never mutate the filesystem or execute shell commands. Use propose_folder, propose_write_file, and propose_bash so Mint can record a proposal for explicit user approval."#; diff --git a/src/renderer/src-web/components/ChatPanel.tsx b/src/renderer/src-web/components/ChatPanel.tsx index 7645533..f18912d 100644 --- a/src/renderer/src-web/components/ChatPanel.tsx +++ b/src/renderer/src-web/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react' +import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react' import { type AgentProgress, type ChatResponse, @@ -292,7 +292,7 @@ interface ChatPanelProps { onSetSmartContext: (enabled: boolean) => void onSetAgentMode: (enabled: boolean) => void onSetProvider: (provider: string) => void - onApproval: (approved: boolean) => void + onApproval: (approved: boolean, autoApproveSession?: boolean) => void onToggleMobileSidebar: () => void settingsConfig: any onSetModel: (model: string) => void @@ -301,16 +301,51 @@ interface ChatPanelProps { function renderFormattedMessage(text: string) { const displayText = readableAssistantText(text) if (!displayText) return null - const parts = displayText.split(/\*\*([\s\S]*?)\*\*/g) - return parts.map((part, index) => { - if (index % 2 === 1) { + + const lines = displayText.split('\n') + return lines.map((line, lineIndex) => { + const headerMatch = line.match(/^(#{1,6})\s+(.*)$/) + + const formatInline = (str: string) => { + const parts = str.split(/\*\*([\s\S]*?)\*\*/g) + return parts.map((part, partIndex) => { + if (partIndex % 2 === 1) { + return ( + + {part} + + ) + } + return part + }) + } + + if (headerMatch) { + const level = headerMatch[1].length + const content = headerMatch[2] + + const style = { + fontWeight: 'bold', + display: 'block', + marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px', + marginBottom: '6px', + fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em', + color: 'var(--text-main)' + } + return ( - - {part} - + + {formatInline(content)} + ) } - return part + + return ( + + {formatInline(line)} + {lineIndex < lines.length - 1 && '\n'} + + ) }) } @@ -361,6 +396,119 @@ function numericSetting(value: unknown, fallback: number) { return Number.isFinite(numeric) ? numeric : fallback } +interface DiffHunk { + oldText: string + newText: string +} + +interface FileChange { + path: string + created: boolean + additions: number + deletions: number + hunks: DiffHunk[] +} + +function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] { + const changes = new Map() + let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null + + for (const event of progress || []) { + if (event.type === 'ToolStart') { + if (event.data.action === 'apply_patch') { + const patch = (event.data.input as any)?.patch + if (patch && typeof patch.path === 'string') { + let additions = 0 + let deletions = 0 + const hunksList: DiffHunk[] = [] + const hunks = patch.hunks + if (Array.isArray(hunks)) { + for (const hunk of hunks) { + const oldText = hunk?.oldText || '' + const newText = hunk?.newText || '' + const oldLines = oldText ? oldText.split('\n').length : 0 + const newLines = newText ? newText.split('\n').length : 0 + deletions += oldLines + additions += newLines + hunksList.push({ oldText, newText }) + } + } + activeEdit = { + action: 'apply_patch', + path: patch.path, + created: false, + additions, + deletions, + hunks: hunksList + } + } + } else if (event.data.action === 'write_file') { + const path = (event.data.input as any)?.path + const fileContent = (event.data.input as any)?.file_content || '' + if (typeof path === 'string') { + const additions = fileContent ? fileContent.split('\n').length : 0 + activeEdit = { + action: 'write_file', + path, + created: true, + additions, + deletions: 0, + hunks: [{ oldText: '', newText: fileContent }] + } + } + } else { + activeEdit = null + } + } else if (event.type === 'ToolEnd') { + if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) { + const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:') + if (!isError) { + try { + const applied = JSON.parse(event.data.result) + const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path] + + for (const path of appliedPaths) { + const existing = changes.get(path) + if (existing) { + existing.additions += activeEdit.additions + existing.deletions += activeEdit.deletions + existing.hunks.push(...activeEdit.hunks) + } else { + changes.set(path, { + path, + created: activeEdit.created, + additions: activeEdit.additions, + deletions: activeEdit.deletions, + hunks: [...activeEdit.hunks] + }) + } + } + } catch (e) { + const path = activeEdit.path + const existing = changes.get(path) + if (existing) { + existing.additions += activeEdit.additions + existing.deletions += activeEdit.deletions + existing.hunks.push(...activeEdit.hunks) + } else { + changes.set(path, { + path, + created: activeEdit.created, + additions: activeEdit.additions, + deletions: activeEdit.deletions, + hunks: [...activeEdit.hunks] + }) + } + } + } + } + activeEdit = null + } + } + + return Array.from(changes.values()) +} + export default function ChatPanel({ interactions, sending, @@ -401,6 +549,8 @@ export default function ChatPanel({ const agentActivities = activitiesFrom(agentProgress) const activeFallbackNotice = fallbackNotice(streamedResponse) const [openActivityIds, setOpenActivityIds] = useState>({}) + const [openReviewIds, setOpenReviewIds] = useState>({}) + const [openFileDiffs, setOpenFileDiffs] = useState>({}) const [toolMenuOpen, setToolMenuOpen] = useState(false) const toolMenuRef = useRef(null) const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName) @@ -959,6 +1109,196 @@ export default function ChatPanel({ ) } + const renderFileChanges = (interaction: any) => { + const interactionId = String(interaction.id) + const progress = agentActivitySnapshots[interactionId] ?? [] + const changes = parseFileChangesFromProgress(progress) + if (changes.length === 0) return null + + const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0) + const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0) + const isOpen = Boolean(openReviewIds[interactionId]) + + return ( +
+ + + {isOpen && ( +
+
+ {changes.map((change) => { + const fileKey = `${interactionId}-${change.path}` + const isDiffOpen = Boolean(openFileDiffs[fileKey]) + const fileName = change.path.split('/').pop() || change.path + const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : '' + + return ( +
+
setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }} + > +
+ + + + + + {fileName} + {dirPath && {dirPath}} + {change.created && new} + +
+
+ {change.additions > 0 && +{change.additions}} + {change.deletions > 0 && -{change.deletions}} + > +
+
+ + {isDiffOpen && ( +
+ {change.hunks.map((hunk, hIdx) => ( +
+ {hunk.oldText && ( +
+ {hunk.oldText.split('\n').map((line, lIdx) => ( +
- {line}
+ ))} +
+ )} + {hunk.newText && ( +
+ {hunk.newText.split('\n').map((line, lIdx) => ( +
+ {line}
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ) + })} +
+
+ )} +
+ ) + } + + const renderActiveFileChanges = () => { + const changes = parseFileChangesFromProgress(agentProgress) + if (changes.length === 0) return null + + const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0) + const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0) + const isOpen = Boolean(openReviewIds['active-run']) + + return ( +
+
+ + + {isOpen && ( +
+ {changes.map((change) => { + const fileKey = `active-${change.path}` + const isDiffOpen = Boolean(openFileDiffs[fileKey]) + const fileName = change.path.split('/').pop() || change.path + const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : '' + + return ( +
+
setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }} + > +
+ + + + + + {fileName} + {dirPath && {dirPath}} + {change.created && new} + +
+
+ {change.additions > 0 && +{change.additions}} + {change.deletions > 0 && -{change.deletions}} + > +
+
+ + {isDiffOpen && ( +
+ {change.hunks.map((hunk, hunkIdx) => ( +
+ {hunk.oldText && ( +
+ {hunk.oldText.split('\n').map((line, lIdx) => ( +
- {line}
+ ))} +
+ )} + {hunk.newText && ( +
+ {hunk.newText.split('\n').map((line, lIdx) => ( +
+ {line}
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+
+ ) + } + return (
{renderCompletedActivity(interaction)} + {renderFileChanges(interaction)}
{renderFormattedMessage(interaction.aiText)}
@@ -1081,6 +1422,7 @@ export default function ChatPanel({
)} + {renderActiveFileChanges()}
{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}
diff --git a/src/renderer/src-web/components/MintDashboard.tsx b/src/renderer/src-web/components/MintDashboard.tsx index 7bbade9..77fc272 100644 --- a/src/renderer/src-web/components/MintDashboard.tsx +++ b/src/renderer/src-web/components/MintDashboard.tsx @@ -318,7 +318,7 @@ export default function MintDashboard() { setAgentMode(enabled) } - async function handleApproval(approved: boolean) { + async function handleApproval(approved: boolean, _autoApproveSession = false) { if (!pendingApproval) return try { await submitToolApproval(pendingApproval.token, approved) diff --git a/src/renderer/src-web/css/settings.css b/src/renderer/src-web/css/settings.css index 81029c4..45c9f13 100644 --- a/src/renderer/src-web/css/settings.css +++ b/src/renderer/src-web/css/settings.css @@ -514,7 +514,7 @@ input[type="color"] { .toggle-visibility:hover { background: var(--accent-soft); - border-color: rgba(139, 92, 246, 0.36); + border-color: rgba(16, 185, 129, 0.36); color: var(--text-main); } @@ -656,13 +656,13 @@ input:checked + .toggle-slider::before { border: 3px solid var(--panel-bg); border-radius: 50%; background: var(--accent); - box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.5), 0 6px 16px rgba(0, 0, 0, 0.25); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.5), 0 6px 16px rgba(0, 0, 0, 0.25); } .range-value { min-width: 68px; padding: 5px 9px; - border: 1px solid rgba(139, 92, 246, 0.3); + border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; background: var(--accent-soft); color: var(--accent-hover); diff --git a/src/renderer/src-web/css/spotlight.css b/src/renderer/src-web/css/spotlight.css index 042fbb8..4ed7652 100644 --- a/src/renderer/src-web/css/spotlight.css +++ b/src/renderer/src-web/css/spotlight.css @@ -1,6 +1,6 @@ :root { --bg-color: rgba(15, 23, 42, 0.85); - --accent: #8b5cf6; + --accent: #10b981; --text-main: #f8fafc; --text-muted: #94a3b8; --border: rgba(255, 255, 255, 0.1); @@ -41,8 +41,8 @@ body { } @keyframes glow { - from { filter: drop-shadow(0 0 2px rgba(139, 92, 246, 0.5)); } - to { filter: drop-shadow(0 0 8px rgba(167, 139, 250, 0.8)); } + from { filter: drop-shadow(0 0 2px rgba(16, 185, 129, 0.5)); } + to { filter: drop-shadow(0 0 8px rgba(52, 211, 153, 0.8)); } } #spotlight-input { @@ -88,7 +88,7 @@ body { } .result-item:hover, .result-item.selected { - background: rgba(139, 92, 246, 0.2); + background: rgba(16, 185, 129, 0.2); } .result-icon { diff --git a/src/renderer/src-web/css/styles.css b/src/renderer/src-web/css/styles.css index 57ff05a..645755b 100644 --- a/src/renderer/src-web/css/styles.css +++ b/src/renderer/src-web/css/styles.css @@ -12,8 +12,8 @@ --text-muted: #8f8f94; --text-soft: #d1d1d4; --placeholder: #818187; - --accent: #8f6cf5; - --accent-hover: #a98cff; + --accent: #10b981; + --accent-hover: #34d399; --border: rgba(255, 255, 255, 0.075); --border-light: rgba(255, 255, 255, 0.11); --shadow: none; @@ -343,9 +343,9 @@ h1 { .accessory-cycle-btn:hover, .accessory-cycle-btn.active { - background: rgba(168, 85, 247, 0.14); - border-color: rgba(168, 85, 247, 0.24); - color: #c4b5fd; + background: rgba(16, 185, 129, 0.14); + border-color: rgba(16, 185, 129, 0.24); + color: #a7f3d0; } .toggle-interaction-btn:hover, @@ -2300,7 +2300,7 @@ input:checked + .slider:before { .mint-status-pill[data-state="thinking"], .model-activity-badge[data-state="thinking"] { - color: #c4b5fd; + color: #a7f3d0; } .mint-status-pill[data-state="thinking"] .mint-status-dot, diff --git a/src/renderer/src-web/css/widget.css b/src/renderer/src-web/css/widget.css index 8f6ddf9..93564c7 100644 --- a/src/renderer/src-web/css/widget.css +++ b/src/renderer/src-web/css/widget.css @@ -1,6 +1,6 @@ :root { - --accent: #8b5cf6; - --accent-glow: rgba(139, 92, 246, 0.6); + --accent: #10b981; + --accent-glow: rgba(16, 185, 129, 0.6); } * { margin: 0; padding: 0; box-sizing: border-box; } diff --git a/src/renderer/src-web/index.css b/src/renderer/src-web/index.css index e528d15..06c3d42 100644 --- a/src/renderer/src-web/index.css +++ b/src/renderer/src-web/index.css @@ -32,7 +32,7 @@ button { height: 100%; overflow: hidden; border: 1px solid rgb(148 163 184 / 20%); - background: radial-gradient(circle at top left, #252058, #111827 52%, #0b1020); + background: radial-gradient(circle at top left, #064e3b, #111827 52%, #0b1020); } .mint-sidebar { @@ -51,7 +51,7 @@ button { } .mint-brand strong { - color: #c4b5fd; + color: #a7f3d0; font-size: 1.25rem; } @@ -89,8 +89,8 @@ button { .mint-sidebar button:hover, .mint-sidebar button.active, .mint-toolbar button:hover { - border-color: rgb(167 139 250 / 28%); - background: rgb(124 58 237 / 18%); + border-color: rgb(52 211 153 / 28%); + background: rgb(16 185 129 / 18%); color: #f8fafc; } @@ -102,7 +102,7 @@ button { } .mint-sidebar__footer button { - border-color: rgb(167 139 250 / 30%); + border-color: rgb(52 211 153 / 30%); text-align: center; } @@ -181,7 +181,7 @@ button { .mint-message--user { justify-self: end; - background: rgb(109 40 217 / 24%); + background: rgb(16 185 129 / 24%); } .mint-message--assistant { @@ -191,7 +191,7 @@ button { } .mint-message small { - color: #a78bfa; + color: #34d399; } .mint-composer { @@ -242,8 +242,8 @@ button { } .mint-composer button { - border-color: rgb(167 139 250 / 32%); - background: #6d28d9; + border-color: rgb(52 211 153 / 32%); + background: #059669; color: white; } @@ -310,7 +310,7 @@ button { } .mint-picture:hover { - border-color: rgb(167 139 250 / 55%); + border-color: rgb(52 211 153 / 55%); transform: translateY(-2px); } @@ -353,12 +353,12 @@ button { } .mint-model__stage.visible { - border-color: rgb(167 139 250 / 58%); - background: radial-gradient(circle, rgb(109 40 217 / 25%), rgb(15 23 42 / 42%)); + border-color: rgb(52 211 153 / 58%); + background: radial-gradient(circle, rgb(16 185 129 / 25%), rgb(15 23 42 / 42%)); } .mint-model__stage strong { - color: #ddd6fe; + color: #d1fae5; } @media (max-width: 700px) { diff --git a/src/renderer/src/components/ChatPanel.tsx b/src/renderer/src/components/ChatPanel.tsx index 29b0789..e093f66 100644 --- a/src/renderer/src/components/ChatPanel.tsx +++ b/src/renderer/src/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, type RefObject } from 'react' +import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, type RefObject } from 'react' import { type AgentProgress, type ChatResponse, @@ -294,7 +294,7 @@ interface ChatPanelProps { onSetAgentMode: (enabled: boolean) => void onSetProvider: (provider: string) => void onSelectWorkspace: () => void - onApproval: (approved: boolean) => void + onApproval: (approved: boolean, autoApproveSession?: boolean) => void settingsConfig: any onSetModel: (model: string) => void } @@ -302,16 +302,51 @@ interface ChatPanelProps { function renderFormattedMessage(text: string) { const displayText = readableAssistantText(text) if (!displayText) return null - const parts = displayText.split(/\*\*([\s\S]*?)\*\*/g) - return parts.map((part, index) => { - if (index % 2 === 1) { + + const lines = displayText.split('\n') + return lines.map((line, lineIndex) => { + const headerMatch = line.match(/^(#{1,6})\s+(.*)$/) + + const formatInline = (str: string) => { + const parts = str.split(/\*\*([\s\S]*?)\*\*/g) + return parts.map((part, partIndex) => { + if (partIndex % 2 === 1) { + return ( + + {part} + + ) + } + return part + }) + } + + if (headerMatch) { + const level = headerMatch[1].length + const content = headerMatch[2] + + const style = { + fontWeight: 'bold', + display: 'block', + marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px', + marginBottom: '6px', + fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em', + color: 'var(--text-main)' + } + return ( - - {part} - + + {formatInline(content)} + ) } - return part + + return ( + + {formatInline(line)} + {lineIndex < lines.length - 1 && '\n'} + + ) }) } @@ -362,6 +397,119 @@ function numericSetting(value: unknown, fallback: number) { return Number.isFinite(numeric) ? numeric : fallback } +interface DiffHunk { + oldText: string + newText: string +} + +interface FileChange { + path: string + created: boolean + additions: number + deletions: number + hunks: DiffHunk[] +} + +function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] { + const changes = new Map() + let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null + + for (const event of progress || []) { + if (event.type === 'ToolStart') { + if (event.data.action === 'apply_patch') { + const patch = (event.data.input as any)?.patch + if (patch && typeof patch.path === 'string') { + let additions = 0 + let deletions = 0 + const hunksList: DiffHunk[] = [] + const hunks = patch.hunks + if (Array.isArray(hunks)) { + for (const hunk of hunks) { + const oldText = hunk?.oldText || '' + const newText = hunk?.newText || '' + const oldLines = oldText ? oldText.split('\n').length : 0 + const newLines = newText ? newText.split('\n').length : 0 + deletions += oldLines + additions += newLines + hunksList.push({ oldText, newText }) + } + } + activeEdit = { + action: 'apply_patch', + path: patch.path, + created: false, + additions, + deletions, + hunks: hunksList + } + } + } else if (event.data.action === 'write_file') { + const path = (event.data.input as any)?.path + const fileContent = (event.data.input as any)?.file_content || '' + if (typeof path === 'string') { + const additions = fileContent ? fileContent.split('\n').length : 0 + activeEdit = { + action: 'write_file', + path, + created: true, + additions, + deletions: 0, + hunks: [{ oldText: '', newText: fileContent }] + } + } + } else { + activeEdit = null + } + } else if (event.type === 'ToolEnd') { + if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) { + const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:') + if (!isError) { + try { + const applied = JSON.parse(event.data.result) + const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path] + + for (const path of appliedPaths) { + const existing = changes.get(path) + if (existing) { + existing.additions += activeEdit.additions + existing.deletions += activeEdit.deletions + existing.hunks.push(...activeEdit.hunks) + } else { + changes.set(path, { + path, + created: activeEdit.created, + additions: activeEdit.additions, + deletions: activeEdit.deletions, + hunks: [...activeEdit.hunks] + }) + } + } + } catch (e) { + const path = activeEdit.path + const existing = changes.get(path) + if (existing) { + existing.additions += activeEdit.additions + existing.deletions += activeEdit.deletions + existing.hunks.push(...activeEdit.hunks) + } else { + changes.set(path, { + path, + created: activeEdit.created, + additions: activeEdit.additions, + deletions: activeEdit.deletions, + hunks: [...activeEdit.hunks] + }) + } + } + } + } + activeEdit = null + } + } + + return Array.from(changes.values()) +} + export default function ChatPanel({ interactions, sending, @@ -403,6 +551,8 @@ export default function ChatPanel({ const agentActivities = activitiesFrom(agentProgress) const activeFallbackNotice = fallbackNotice(streamedResponse) const [openActivityIds, setOpenActivityIds] = useState>({}) + const [openReviewIds, setOpenReviewIds] = useState>({}) + const [openFileDiffs, setOpenFileDiffs] = useState>({}) const [toolMenuOpen, setToolMenuOpen] = useState(false) const [isRecording, setIsRecording] = useState(false) const [voiceMode, setVoiceMode] = useState(false) @@ -919,6 +1069,196 @@ export default function ChatPanel({ ) } + const renderFileChanges = (interaction: any) => { + const interactionId = String(interaction.id) + const progress = agentActivitySnapshots[interactionId] ?? [] + const changes = parseFileChangesFromProgress(progress) + if (changes.length === 0) return null + + const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0) + const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0) + const isOpen = Boolean(openReviewIds[interactionId]) + + return ( +
+ + + {isOpen && ( +
+
+ {changes.map((change) => { + const fileKey = `${interactionId}-${change.path}` + const isDiffOpen = Boolean(openFileDiffs[fileKey]) + const fileName = change.path.split('/').pop() || change.path + const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : '' + + return ( +
+
setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }} + > +
+ + + + + + {fileName} + {dirPath && {dirPath}} + {change.created && new} + +
+
+ {change.additions > 0 && +{change.additions}} + {change.deletions > 0 && -{change.deletions}} + > +
+
+ + {isDiffOpen && ( +
+ {change.hunks.map((hunk, hIdx) => ( +
+ {hunk.oldText && ( +
+ {hunk.oldText.split('\n').map((line, lIdx) => ( +
- {line}
+ ))} +
+ )} + {hunk.newText && ( +
+ {hunk.newText.split('\n').map((line, lIdx) => ( +
+ {line}
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ) + })} +
+
+ )} +
+ ) + } + + const renderActiveFileChanges = () => { + const changes = parseFileChangesFromProgress(agentProgress) + if (changes.length === 0) return null + + const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0) + const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0) + const isOpen = Boolean(openReviewIds['active-run']) + + return ( +
+
+ + + {isOpen && ( +
+ {changes.map((change) => { + const fileKey = `active-${change.path}` + const isDiffOpen = Boolean(openFileDiffs[fileKey]) + const fileName = change.path.split('/').pop() || change.path + const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : '' + + return ( +
+
setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }} + > +
+ + + + + + {fileName} + {dirPath && {dirPath}} + {change.created && new} + +
+
+ {change.additions > 0 && +{change.additions}} + {change.deletions > 0 && -{change.deletions}} + > +
+
+ + {isDiffOpen && ( +
+ {change.hunks.map((hunk, hunkIdx) => ( +
+ {hunk.oldText && ( +
+ {hunk.oldText.split('\n').map((line, lIdx) => ( +
- {line}
+ ))} +
+ )} + {hunk.newText && ( +
+ {hunk.newText.split('\n').map((line, lIdx) => ( +
+ {line}
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+
+ ) + } + return (
@@ -939,6 +1279,7 @@ export default function ChatPanel({
{renderCompletedActivity(interaction)} + {renderFileChanges(interaction)}
{renderFormattedMessage(interaction.aiText)}
@@ -974,6 +1315,7 @@ export default function ChatPanel({
)} + {renderActiveFileChanges()}
{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}
@@ -1011,6 +1353,7 @@ export default function ChatPanel({
+
diff --git a/src/renderer/src/components/MintDashboard.tsx b/src/renderer/src/components/MintDashboard.tsx index 0ad40f7..407b47c 100644 --- a/src/renderer/src/components/MintDashboard.tsx +++ b/src/renderer/src/components/MintDashboard.tsx @@ -237,6 +237,8 @@ export default function MintDashboard() { const [imageAttachments, setImageAttachments] = useState>([]) const [documentAttachment, setDocumentAttachment] = useState(null) const [pendingApproval, setPendingApproval] = useState(null) + const [sessionAutoApproved, setSessionAutoApproved] = useState(false) + const sessionAutoApprovedRef = useRef(false) const [modelVisible, setModelVisible] = useState(() => window.localStorage.getItem('mint:model-visible') !== 'false') const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('mint:sidebar-collapsed') === 'true') const [smartContext, setSmartContext] = useState(() => window.localStorage.getItem('mint:smart-context') !== 'false') @@ -318,7 +320,15 @@ export default function MintDashboard() { applyThemeStyles(loaded) }) - const unlistenPromise = listen('tool-approval-requested', (event) => setPendingApproval(event.payload)) + const unlistenPromise = listen('tool-approval-requested', (event) => { + if (sessionAutoApprovedRef.current) { + submitToolApproval(event.payload.token, true).catch((err) => { + console.error("Auto approval failed:", err) + }) + } else { + setPendingApproval(event.payload) + } + }) return () => { unlistenPromise?.then?.((unlisten) => unlisten?.()) unlistenSpotlight?.then?.((unlisten) => unlisten?.()) @@ -395,9 +405,20 @@ export default function MintDashboard() { setWorkspacePath(next) } - async function handleApproval(approved: boolean) { + useEffect(() => { + if (!sending) { + sessionAutoApprovedRef.current = false + setSessionAutoApproved(false) + } + }, [sending]) + + async function handleApproval(approved: boolean, autoApproveSession = false) { if (!pendingApproval) return try { + if (autoApproveSession) { + sessionAutoApprovedRef.current = true + setSessionAutoApproved(true) + } await submitToolApproval(pendingApproval.token, approved) } catch (reason) { setError(errorMessage(reason)) diff --git a/src/renderer/src/components/ProactiveGlow.tsx b/src/renderer/src/components/ProactiveGlow.tsx index c965642..4e94d6c 100644 --- a/src/renderer/src/components/ProactiveGlow.tsx +++ b/src/renderer/src/components/ProactiveGlow.tsx @@ -8,7 +8,7 @@ export default function ProactiveGlow() { position: absolute; inset: 0; pointer-events: none; - box-shadow: inset 0 0 70px rgba(139, 92, 246, 0.2); + box-shadow: inset 0 0 70px rgba(16, 185, 129, 0.2); opacity: 0.7; z-index: 100; } diff --git a/src/renderer/src/components/ScreenPicker.tsx b/src/renderer/src/components/ScreenPicker.tsx index 1b869c6..a07af50 100644 --- a/src/renderer/src/components/ScreenPicker.tsx +++ b/src/renderer/src/components/ScreenPicker.tsx @@ -119,7 +119,7 @@ export default function ScreenPicker() { const overlayCtx = overlay.getContext('2d') if (!overlayCtx) return overlayCtx.clearRect(0, 0, overlay.width, overlay.height) - overlayCtx.strokeStyle = isTranslateMode ? '#8b5cf6' : '#00ff88' + overlayCtx.strokeStyle = isTranslateMode ? '#10b981' : '#00ff88' overlayCtx.lineWidth = 3 overlayCtx.strokeRect(rect.x, rect.y, rect.width, rect.height) } @@ -134,7 +134,7 @@ export default function ScreenPicker() { const rect = normalizeRect(currentCoords) overlayCtx.clearRect(rect.x, rect.y, rect.width, rect.height) - overlayCtx.strokeStyle = isTranslateMode ? '#8b5cf6' : '#00ff88' + overlayCtx.strokeStyle = isTranslateMode ? '#10b981' : '#00ff88' overlayCtx.lineWidth = 2 overlayCtx.strokeRect(rect.x, rect.y, rect.width, rect.height) } @@ -341,7 +341,7 @@ export default function ScreenPicker() { position: absolute; inset: 0; pointer-events: none; - box-shadow: inset 0 0 70px rgba(139, 92, 246, 0.18); + box-shadow: inset 0 0 70px rgba(16, 185, 129, 0.18); z-index: 5; } @@ -403,21 +403,21 @@ export default function ScreenPicker() { } .btn-translate { - background: rgba(139, 92, 246, 0.15); - color: #c4b5fd; - border-color: rgba(139, 92, 246, 0.3); + background: rgba(16, 185, 129, 0.15); + color: #a7f3d0; + border-color: rgba(16, 185, 129, 0.3); } .btn-translate:hover { - background: rgba(139, 92, 246, 0.3); - color: #f5f3ff; + background: rgba(16, 185, 129, 0.3); + color: #ecfdf5; } .btn-translate.active { - background: #8b5cf6; + background: #10b981; color: #ffffff; - border-color: #7c3aed; - box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); + border-color: #059669; + box-shadow: 0 0 15px rgba(16, 185, 129, 0.4); } .btn-primary { @@ -448,7 +448,7 @@ export default function ScreenPicker() { height: 14px; border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; - border-top-color: #8b5cf6; + border-top-color: #10b981; animation: picker-spin 0.8s linear infinite; margin-right: 8px; } diff --git a/src/renderer/src/css/settings.css b/src/renderer/src/css/settings.css index 8084ddc..38e5231 100644 --- a/src/renderer/src/css/settings.css +++ b/src/renderer/src/css/settings.css @@ -486,7 +486,7 @@ input[type="color"] { .toggle-visibility:hover { background: var(--accent-soft); - border-color: rgba(139, 92, 246, 0.36); + border-color: rgba(16, 185, 129, 0.36); color: var(--text-main); } @@ -628,13 +628,13 @@ input:checked + .toggle-slider::before { border: 3px solid var(--panel-bg); border-radius: 50%; background: var(--accent); - box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.5), 0 6px 16px rgba(0, 0, 0, 0.25); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.5), 0 6px 16px rgba(0, 0, 0, 0.25); } .range-value { min-width: 68px; padding: 5px 9px; - border: 1px solid rgba(139, 92, 246, 0.3); + border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; background: var(--accent-soft); color: var(--accent-hover); diff --git a/src/renderer/src/css/spotlight.css b/src/renderer/src/css/spotlight.css index 4e9bc32..7169381 100644 --- a/src/renderer/src/css/spotlight.css +++ b/src/renderer/src/css/spotlight.css @@ -1,6 +1,6 @@ :root { --bg-color: rgba(15, 23, 42, 0.85); - --accent: #8b5cf6; + --accent: #10b981; --text-main: #f8fafc; --text-muted: #94a3b8; --border: rgba(255, 255, 255, 0.1); @@ -82,7 +82,7 @@ body { } .result-item:hover, .result-item.selected { - background: rgba(139, 92, 246, 0.2); + background: rgba(16, 185, 129, 0.2); } .result-icon { diff --git a/src/renderer/src/css/styles.css b/src/renderer/src/css/styles.css index 61d217e..5c454b7 100644 --- a/src/renderer/src/css/styles.css +++ b/src/renderer/src/css/styles.css @@ -12,8 +12,8 @@ --text-muted: #8f8f94; --text-soft: #d1d1d4; --placeholder: #818187; - --accent: #8f6cf5; - --accent-hover: #a98cff; + --accent: #10b981; + --accent-hover: #34d399; --border: rgba(255, 255, 255, 0.075); --border-light: rgba(255, 255, 255, 0.11); --shadow: none; @@ -343,9 +343,9 @@ h1 { .accessory-cycle-btn:hover, .accessory-cycle-btn.active { - background: rgba(168, 85, 247, 0.14); - border-color: rgba(168, 85, 247, 0.24); - color: #c4b5fd; + background: rgba(16, 185, 129, 0.14); + border-color: rgba(16, 185, 129, 0.24); + color: #a7f3d0; } .toggle-interaction-btn:hover, @@ -2282,7 +2282,7 @@ input:checked + .slider:before { .mint-status-pill[data-state="thinking"], .model-activity-badge[data-state="thinking"] { - color: #c4b5fd; + color: #a7f3d0; } .mint-status-pill[data-state="thinking"] .mint-status-dot, diff --git a/src/renderer/src/css/widget.css b/src/renderer/src/css/widget.css index dfe3205..8ed631d 100644 --- a/src/renderer/src/css/widget.css +++ b/src/renderer/src/css/widget.css @@ -1,6 +1,6 @@ :root { - --accent: #8b5cf6; - --accent-glow: rgba(139, 92, 246, 0.6); + --accent: #10b981; + --accent-glow: rgba(16, 185, 129, 0.6); } * { margin: 0; padding: 0; box-sizing: border-box; } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index e528d15..06c3d42 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -32,7 +32,7 @@ button { height: 100%; overflow: hidden; border: 1px solid rgb(148 163 184 / 20%); - background: radial-gradient(circle at top left, #252058, #111827 52%, #0b1020); + background: radial-gradient(circle at top left, #064e3b, #111827 52%, #0b1020); } .mint-sidebar { @@ -51,7 +51,7 @@ button { } .mint-brand strong { - color: #c4b5fd; + color: #a7f3d0; font-size: 1.25rem; } @@ -89,8 +89,8 @@ button { .mint-sidebar button:hover, .mint-sidebar button.active, .mint-toolbar button:hover { - border-color: rgb(167 139 250 / 28%); - background: rgb(124 58 237 / 18%); + border-color: rgb(52 211 153 / 28%); + background: rgb(16 185 129 / 18%); color: #f8fafc; } @@ -102,7 +102,7 @@ button { } .mint-sidebar__footer button { - border-color: rgb(167 139 250 / 30%); + border-color: rgb(52 211 153 / 30%); text-align: center; } @@ -181,7 +181,7 @@ button { .mint-message--user { justify-self: end; - background: rgb(109 40 217 / 24%); + background: rgb(16 185 129 / 24%); } .mint-message--assistant { @@ -191,7 +191,7 @@ button { } .mint-message small { - color: #a78bfa; + color: #34d399; } .mint-composer { @@ -242,8 +242,8 @@ button { } .mint-composer button { - border-color: rgb(167 139 250 / 32%); - background: #6d28d9; + border-color: rgb(52 211 153 / 32%); + background: #059669; color: white; } @@ -310,7 +310,7 @@ button { } .mint-picture:hover { - border-color: rgb(167 139 250 / 55%); + border-color: rgb(52 211 153 / 55%); transform: translateY(-2px); } @@ -353,12 +353,12 @@ button { } .mint-model__stage.visible { - border-color: rgb(167 139 250 / 58%); - background: radial-gradient(circle, rgb(109 40 217 / 25%), rgb(15 23 42 / 42%)); + border-color: rgb(52 211 153 / 58%); + background: radial-gradient(circle, rgb(16 185 129 / 25%), rgb(15 23 42 / 42%)); } .mint-model__stage strong { - color: #ddd6fe; + color: #d1fae5; } @media (max-width: 700px) {