From 02674bc9ad39b618b96816c6aafaa06ee386923b Mon Sep 17 00:00:00 2001 From: Djidjelli Youcef Date: Thu, 4 Jun 2026 19:09:11 +0200 Subject: [PATCH 1/2] feat: edit a text selection with AI from the editor toolbar Adds a wand button to the editor toolbar that sends the selected text to an AI provider and replaces the selection inline. Addresses the per-selection editing part of #37 (the diff/review part is tracked separately in #56). - New `ai_transform_text` command: transforms a snippet through the existing provider CLIs and returns the edited text (no whole-note rewrite). Reasoning model "thinking" output is stripped before applying. - AiSelectionModal: customizable one-click presets plus a free instruction, an animated status while running, and in-modal errors. - Settings (Integrations): selectable provider cards to choose the default, per-provider model selection for Ollama and OpenCode (listed via their CLIs), and an editable, reorderable list of quick actions. --- src-tauri/src/lib.rs | 165 ++++++++++ src/App.css | 29 ++ src/components/ai/AiSelectionModal.tsx | 257 +++++++++++++++ src/components/ai/presets.ts | 22 ++ src/components/editor/Editor.tsx | 53 +++ src/components/icons/index.tsx | 33 ++ .../settings/QuickActionsEditor.tsx | 301 ++++++++++++++++++ .../settings/ToolsSettingsSection.tsx | 226 +++++++++++-- src/services/ai.ts | 17 + src/types/note.ts | 3 + 10 files changed, 1084 insertions(+), 22 deletions(-) create mode 100644 src/components/ai/AiSelectionModal.tsx create mode 100644 src/components/ai/presets.ts create mode 100644 src/components/settings/QuickActionsEditor.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..bd33dfcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -102,6 +102,12 @@ pub struct AppConfig { pub notes_folder: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiPreset { + pub label: String, + pub instruction: String, +} + // Per-folder settings (stored in .scratch/settings.json within notes folder) #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -124,6 +130,12 @@ pub struct Settings { pub custom_editor_width_px: Option, #[serde(rename = "ollamaModel")] pub ollama_model: Option, + #[serde(rename = "opencodeModel")] + pub opencode_model: Option, + #[serde(rename = "defaultAiProvider")] + pub default_ai_provider: Option, + #[serde(rename = "aiSelectionPresets")] + pub ai_selection_presets: Option>, #[serde(rename = "foldersEnabled")] pub folders_enabled: Option, #[serde(rename = "ignoredPatterns")] @@ -3353,6 +3365,56 @@ async fn ai_check_ollama_cli() -> Result { .map_err(|e| format!("Failed to check Ollama CLI: {}", e))? } +#[tauri::command] +async fn ai_list_ollama_models() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| { + let path = get_expanded_path(); + let output = no_window_cmd("ollama") + .env("PATH", &path) + .arg("list") + .output() + .map_err(|e| format!("Failed to run ollama list: {}", e))?; + if !output.status.success() { + return Err("Ollama is not running or returned an error.".to_string()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let models = stdout + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .map(|s| s.to_string()) + .collect(); + Ok(models) + }) + .await + .map_err(|e| format!("Failed to list Ollama models: {}", e))? +} + +#[tauri::command] +async fn ai_list_opencode_models() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| { + let path = get_expanded_path(); + let output = no_window_cmd("opencode") + .env("PATH", &path) + .arg("models") + .output() + .map_err(|e| format!("Failed to run opencode models: {}", e))?; + if !output.status.success() { + return Err("OpenCode failed to list models.".to_string()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let models = stdout + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|s| s.to_string()) + .collect(); + Ok(models) + }) + .await + .map_err(|e| format!("Failed to list OpenCode models: {}", e))? +} + #[tauri::command] async fn ai_execute_ollama( file_path: String, @@ -3494,6 +3556,106 @@ async fn ai_execute_ollama( } } +#[tauri::command] +async fn ai_transform_text( + provider: String, + text: String, + prompt: String, + model: Option, + state: State<'_, AppState>, +) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + + let instructions = format!( + "You are a Markdown editor. Edit the Markdown in as requested in \ + , and return ONLY the resulting Markdown, with no commentary, \ + explanation, or code fences.\n\n\ + \n{prompt}\n\n\n\ + \n{text}\n\n\n\ + Return only the edited Markdown." + ); + + let (cli_name, command, args, stdin, not_found, cwd, env) = match provider.as_str() { + "claude" => ( + "Claude", + "claude", + vec!["--dangerously-skip-permissions".to_string(), "--print".to_string()], + instructions, + "Claude CLI not found. Please install it from https://claude.ai/code", + None, + None, + ), + "codex" => ( + "Codex", + "codex", + vec![ + "exec".to_string(), + "--skip-git-repo-check".to_string(), + "--dangerously-bypass-approvals-and-sandbox".to_string(), + "-".to_string(), + ], + instructions, + "Codex CLI not found. Please install it from https://github.com/openai/codex", + None, + None, + ), + "opencode" => { + let mut args = vec!["run".to_string()]; + if let Some(m) = model.as_deref().map(str::trim).filter(|m| !m.is_empty()) { + args.push("--model".to_string()); + args.push(m.to_string()); + } + args.push("--".to_string()); + args.push(instructions); + ( + "OpenCode", + "opencode", + args, + String::new(), + "OpenCode CLI not found. Please install it from https://opencode.ai", + Some(folder), + Some(vec![( + "OPENCODE_PERMISSION".to_string(), + r#"{"*":"allow","bash":"deny","task":"deny","webfetch":"deny","websearch":"deny","codesearch":"deny","skill":"deny","external_directory":"deny","doom_loop":"deny"}"#.to_string(), + )]), + ) + } + "ollama" => { + let model_name = match model { + Some(m) if !m.trim().is_empty() => m.trim().to_string(), + _ => "qwen3:8b".to_string(), + }; + ( + "Ollama", + "ollama", + vec!["run".to_string(), model_name], + instructions, + "Ollama CLI not found. Please install it from https://ollama.com", + None, + None, + ) + } + other => return Err(format!("Unknown AI provider: {}", other)), + }; + + execute_ai_cli( + cli_name, + command.to_string(), + args, + stdin, + not_found.to_string(), + cwd, + env, + ) + .await +} + /// Check if a markdown file is inside the configured notes folder. /// If so, emit a "select-note" event to the main window and focus it, returning true. /// Returns false on any failure so callers can fall back to create_preview_window. @@ -3850,10 +4012,13 @@ pub fn run() { ai_check_codex_cli, ai_check_opencode_cli, ai_check_ollama_cli, + ai_list_ollama_models, + ai_list_opencode_models, ai_execute_claude, ai_execute_codex, ai_execute_opencode, ai_execute_ollama, + ai_transform_text, read_file_direct, save_file_direct, import_file_to_folder, diff --git a/src/App.css b/src/App.css index 3b3904d9..cfdbd0e9 100644 --- a/src/App.css +++ b/src/App.css @@ -278,12 +278,41 @@ html.dark { animation: pulse-gentle 1.3s ease-in-out infinite; } +@keyframes text-shimmer { + from { + background-position: 200% center; + } + to { + background-position: -200% center; + } +} + +.text-shimmer { + background: linear-gradient( + 90deg, + var(--color-text-muted) 35%, + var(--color-text) 50%, + var(--color-text-muted) 65% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: text-shimmer 2.2s linear infinite; +} + @media (prefers-reduced-motion: reduce) { .animate-spin-slow, .animate-bounce-gentle, .animate-pulse-gentle { animation: none; } + .text-shimmer { + animation: none; + color: var(--color-text-muted); + -webkit-text-fill-color: var(--color-text-muted); + } } /* Staggered fade-in-up animation for welcome screen */ diff --git a/src/components/ai/AiSelectionModal.tsx b/src/components/ai/AiSelectionModal.tsx new file mode 100644 index 00000000..d34366ad --- /dev/null +++ b/src/components/ai/AiSelectionModal.tsx @@ -0,0 +1,257 @@ +import { useState, useRef, useEffect, type KeyboardEvent } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + SpinnerIcon, + ClaudeIcon, + CodexIcon, + OpenCodeIcon, + OllamaIcon, +} from "../icons"; +import { + aiTransformSelection, + getAvailableAiProviders, + type AiProvider, +} from "../../services/ai"; +import type { Settings } from "../../types/note"; +import { DEFAULT_AI_PRESETS, type AiPreset } from "./presets"; + +interface AiSelectionModalProps { + open: boolean; + selectedText: string; + onClose: () => void; + onApply: (text: string) => void; +} + +function stripThinking(text: string): string { + return text + .replace(/[\s\S]*?<\/think>/gi, "") + .replace(/Thinking\.\.\.[\s\S]*?\.\.\.done thinking\./gi, "") + .trim(); +} + +export function AiSelectionModal({ + open, + selectedText, + onClose, + onApply, +}: AiSelectionModalProps) { + const [provider, setProvider] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(true); + const [model, setModel] = useState(""); + const [guidance, setGuidance] = useState(""); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [presets, setPresets] = useState(DEFAULT_AI_PRESETS); + const inputRef = useRef(null); + + const ProviderIcon = + provider === "codex" + ? CodexIcon + : provider === "opencode" + ? OpenCodeIcon + : provider === "ollama" + ? OllamaIcon + : ClaudeIcon; + const providerName = + provider === "codex" + ? "Codex" + : provider === "opencode" + ? "OpenCode" + : provider === "ollama" + ? "Ollama" + : "Claude"; + + useEffect(() => { + if (!open) return; + setLoadingProviders(true); + Promise.all([getAvailableAiProviders(), invoke("get_settings")]) + .then(([providers, settings]) => { + const preferred = settings.defaultAiProvider; + setProvider( + preferred && providers.includes(preferred) + ? preferred + : (providers[0] ?? null), + ); + setPresets( + settings.aiSelectionPresets?.length + ? settings.aiSelectionPresets + : DEFAULT_AI_PRESETS, + ); + }) + .catch(() => setProvider(null)) + .finally(() => setLoadingProviders(false)); + }, [open]); + + useEffect(() => { + if (!open) return; + invoke("get_settings") + .then((settings) => { + if (provider === "ollama") + setModel(settings.ollamaModel || "qwen3:8b"); + else if (provider === "opencode") + setModel(settings.opencodeModel || ""); + else setModel(""); + }) + .catch(() => {}); + }, [open, provider]); + + useEffect(() => { + if (!open) { + setGuidance(""); + setError(null); + return; + } + const focus = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(focus); + }, [open]); + + useEffect(() => { + if (!open) return; + const handleEscape = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onClose]); + + const run = async (instruction: string) => { + const trimmed = instruction.trim(); + if (!provider || isRunning || !trimmed) return; + + setError(null); + setIsRunning(true); + try { + const result = await aiTransformSelection( + provider, + selectedText, + trimmed, + model || undefined, + ); + if (result.success) { + const cleaned = stripThinking(result.output); + if (cleaned) { + onApply(cleaned); + } else { + setError("The AI returned an empty result."); + } + } else { + setError(result.error || "The AI request failed. Please try again."); + } + } catch (err) { + setError(err instanceof Error ? err.message : "The AI request failed."); + } finally { + setIsRunning(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + run(guidance); + } + }; + + if (!open) return null; + + return ( + <> +
+
+
+
+
+ + { + setGuidance(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="Tell the AI how to edit the selection..." + disabled={isRunning || (!loadingProviders && !provider)} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + className="flex-1 text-[17px] bg-transparent outline-none text-text placeholder-text-muted/50 disabled:opacity-50" + /> +
+
+ +
+ {loadingProviders ? ( +
+ + Detecting AI providers... +
+ ) : !provider ? ( +
+ No AI provider installed. Add one from Settings to use this + feature. +
+ ) : isRunning ? ( +
+ + {providerName} is editing your selection... + +
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} +
+ {presets + .filter((p) => p.label.trim() && p.instruction.trim()) + .map((preset, i) => ( + + ))} +
+ +
+ {selectedText} +
+ +
+
+ + Esc + + to close +
+
+ + Enter + + to submit +
+
+ + )} +
+
+
+ + ); +} diff --git a/src/components/ai/presets.ts b/src/components/ai/presets.ts new file mode 100644 index 00000000..3edb8272 --- /dev/null +++ b/src/components/ai/presets.ts @@ -0,0 +1,22 @@ +export interface AiPreset { + label: string; + instruction: string; +} + +export const DEFAULT_AI_PRESETS: AiPreset[] = [ + { + label: "Fix grammar", + instruction: + "Fix spelling and grammar mistakes. Keep the meaning, tone, and language; change only what is incorrect.", + }, + { + label: "Summarize", + instruction: + "Summarize this into a concise version, keeping the key points and the original language.", + }, + { + label: "Rephrase", + instruction: + "Rephrase to improve clarity and flow while preserving the meaning and the original language.", + }, +]; diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..3e19bcbc 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -107,7 +107,9 @@ import { MarkdownIcon, MarkdownOffIcon, FolderPlusIcon, + SparklesIcon, } from "../icons"; +import { AiSelectionModal } from "../ai/AiSelectionModal"; function formatDateTime(timestamp: number): string { const date = new Date(timestamp * 1000); @@ -246,6 +248,7 @@ interface FormatBarProps { onAddLink: () => void; onAddBlockMath: () => void; onAddImage: () => void; + onAskAi: () => void; } // FormatBar must re-render with parent to reflect editor.isActive() state changes @@ -255,6 +258,7 @@ function FormatBar({ onAddLink, onAddBlockMath, onAddImage, + onAskAi, }: FormatBarProps) { const [tableMenuOpen, setTableMenuOpen] = useState(false); @@ -423,6 +427,18 @@ function FormatBar({ + +
+ + + +
); } @@ -1552,6 +1568,35 @@ export function Editor({ }, []); // Only run cleanup on unmount, not when saveNote changes // Link handlers - show inline popup at cursor position + const [aiSelection, setAiSelection] = useState<{ + from: number; + to: number; + text: string; + } | null>(null); + + const handleAskAi = useCallback(() => { + if (!editor) return; + const { from, to } = editor.state.selection; + if (from === to) return; + const text = editor.state.doc.textBetween(from, to, "\n"); + if (text.trim()) setAiSelection({ from, to, text }); + }, [editor]); + + const applyAiSelection = useCallback( + (result: string) => { + if (!editor || !aiSelection) return; + const manager = editor.storage.markdown?.manager; + const parsed = manager ? manager.parse(result) : result; + editor + .chain() + .focus() + .insertContentAt({ from: aiSelection.from, to: aiSelection.to }, parsed) + .run(); + setAiSelection(null); + }, + [editor, aiSelection], + ); + const handleAddLink = useCallback(() => { if (!editor) return; @@ -2367,9 +2412,17 @@ export function Editor({ onAddLink={handleAddLink} onAddBlockMath={handleAddBlockMath} onAddImage={handleAddImage} + onAskAi={handleAskAi} />
+ setAiSelection(null)} + onApply={applyAiSelection} + /> + {/* Editor content area with resize handles overlay */}
{!focusMode && !sourceMode && ( diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index c2647688..3edb7ec7 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -5,6 +5,19 @@ interface IconProps { className?: string; } +export function GripVerticalIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + + + ); +} + export function PilcrowIcon({ className = "w-4.5 h-4.5" }: IconProps) { return ( + + + + + + + ); +} + export function SpinnerIcon({ className = "w-4.5 h-4.5 animate-spin", }: IconProps) { diff --git a/src/components/settings/QuickActionsEditor.tsx b/src/components/settings/QuickActionsEditor.tsx new file mode 100644 index 00000000..c0ac2ac4 --- /dev/null +++ b/src/components/settings/QuickActionsEditor.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVerticalIcon, TrashIcon, PlusIcon } from "../icons"; +import type { Settings } from "../../types/note"; +import { DEFAULT_AI_PRESETS } from "../ai/presets"; + +interface Item { + id: string; + label: string; + instruction: string; +} + +interface Draft { + id: string; + label: string; + instruction: string; + isNew: boolean; +} + +function newId() { + return crypto.randomUUID(); +} + +interface FormProps { + draft: Draft; + onChange: (field: "label" | "instruction", value: string) => void; + onCancel: () => void; + onSave: () => void; + onDelete?: () => void; +} + +function PresetForm({ draft, onChange, onCancel, onSave, onDelete }: FormProps) { + const valid = draft.label.trim() && draft.instruction.trim(); + return ( +
+ onChange("label", e.target.value)} + placeholder="Action name" + className="w-full text-[15px] font-semibold bg-transparent outline-none text-text placeholder-text-muted/50" + /> +
+
+ Prompt sent to the model +
+