Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code": "^3.18.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-image": "^3.18.0",
"@tiptap/extension-link": "^3.18.0",
Expand Down
165 changes: 165 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ pub struct AppConfig {
pub notes_folder: Option<String>,
}

#[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 {
Expand All @@ -124,6 +130,12 @@ pub struct Settings {
pub custom_editor_width_px: Option<u32>,
#[serde(rename = "ollamaModel")]
pub ollama_model: Option<String>,
#[serde(rename = "opencodeModel")]
pub opencode_model: Option<String>,
#[serde(rename = "defaultAiProvider")]
pub default_ai_provider: Option<String>,
#[serde(rename = "aiSelectionPresets")]
pub ai_selection_presets: Option<Vec<AiPreset>>,
#[serde(rename = "foldersEnabled")]
pub folders_enabled: Option<bool>,
#[serde(rename = "ignoredPatterns")]
Expand Down Expand Up @@ -3353,6 +3365,56 @@ async fn ai_check_ollama_cli() -> Result<bool, String> {
.map_err(|e| format!("Failed to check Ollama CLI: {}", e))?
}

#[tauri::command]
async fn ai_list_ollama_models() -> Result<Vec<String>, 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<Vec<String>, 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,
Expand Down Expand Up @@ -3494,6 +3556,106 @@ async fn ai_execute_ollama(
}
}

#[tauri::command]
async fn ai_transform_text(
provider: String,
text: String,
prompt: String,
model: Option<String>,
state: State<'_, AppState>,
) -> Result<AiExecutionResult, String> {
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")?
};
Comment on lines +3567 to +3573

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move notes-folder lookup into the OpenCode branch only.

ai_transform_text currently rejects all providers when no notes folder is configured, even though only the OpenCode path needs a working directory.

Suggested fix
 async fn ai_transform_text(
     provider: String,
     text: String,
     prompt: String,
     model: Option<String>,
     state: State<'_, AppState>,
 ) -> Result<AiExecutionResult, String> {
-    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 <text> as requested in \
          <request>, and return ONLY the resulting Markdown, with no commentary, \
          explanation, or code fences.\n\n\
@@
         "opencode" => {
+            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 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());
             }

Also applies to: 3608-3627

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 3567 - 3573, ai_transform_text currently
reads app_config and errors if notes_folder is unset before provider dispatch;
move the notes_folder lookup and the error (the block that reads
state.app_config and accesses app_config.notes_folder) into the OpenCode branch
only so other providers are not rejected when notes_folder is absent. Locate the
initial folder extraction (the let folder = { ...
app_config.notes_folder.clone().ok_or("Notes folder not set")? }) and remove it
from the shared pre-dispatch logic, then perform that same read/ok_or logic
inside the OpenCode handling branch (the branch that runs OpenCode-specific
code), referencing the same symbols (state.app_config and
app_config.notes_folder) so only OpenCode requires the working directory; do the
same relocation for the similar block currently duplicated around the other
OpenCode section (the corresponding code near the second OpenCode handling).


let instructions = format!(
"You are a Markdown editor. Edit the Markdown in <text> as requested in \
<request>, and return ONLY the resulting Markdown, with no commentary, \
explanation, or code fences.\n\n\
<request>\n{prompt}\n</request>\n\n\
<text>\n{text}\n</text>\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.
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading