diff --git a/package-lock.json b/package-lock.json index 55003d64..4ba72dd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "highlight.js": "^11.11.1", "katex": "^0.16.33", "lowlight": "^3.3.0", + "motion": "^13.2.0", "react": "^19.1.0", "react-colorful": "^5.6.1", "react-dom": "^19.1.0", @@ -3665,6 +3666,29 @@ } } }, + "node_modules/framer-motion": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz", + "integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.2.0", + "motion-utils": "^13.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4118,6 +4142,43 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/motion": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.2.0.tgz", + "integrity": "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^13.2.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz", + "integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.0.0" + } + }, + "node_modules/motion-utils": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index fb889190..99197728 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "deploy": "bash scripts/deploy-app.sh" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -44,6 +45,7 @@ "highlight.js": "^11.11.1", "katex": "^0.16.33", "lowlight": "^3.3.0", + "motion": "^13.2.0", "react": "^19.1.0", "react-colorful": "^5.6.1", "react-dom": "^19.1.0", diff --git a/scripts/deploy-app.sh b/scripts/deploy-app.sh new file mode 100755 index 00000000..ed687ce8 --- /dev/null +++ b/scripts/deploy-app.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build Scratch and install it into /Applications here and on any Macs given +# as arguments (SSH hosts, e.g. `macmini`). Defaults to this Mac plus macmini. +# +# scripts/deploy-app.sh # this Mac + macmini +# scripts/deploy-app.sh --local-only # just this Mac +# scripts/deploy-app.sh macmini-lan # this Mac + a specific host +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_NAME="Scratch.app" +BUILT_APP="$REPO_ROOT/src-tauri/target/release/bundle/macos/$APP_NAME" +LOCAL_ARCH="$(uname -m)" + +hosts=() +if [[ "${1:-}" == "--local-only" ]]; then + shift +elif [[ $# -gt 0 ]]; then + hosts=("$@") +else + hosts=(macmini) +fi + +echo "==> Building $APP_NAME ($LOCAL_ARCH)" +cd "$REPO_ROOT" +# The updater artifact needs a signing key we don't have; the .app itself is +# built before that step, so a failure there is not a failed build. +npm run tauri build -- --bundles app || true +[[ -d "$BUILT_APP" ]] || { echo "Build produced no $APP_NAME" >&2; exit 1; } + +install_local() { + echo "==> Installing locally" + osascript -e 'tell application "Scratch" to quit' >/dev/null 2>&1 || true + while pgrep -f "/Applications/$APP_NAME" >/dev/null; do sleep 1; done + rm -rf "/Applications/$APP_NAME" + cp -R "$BUILT_APP" /Applications/ + xattr -dr com.apple.quarantine "/Applications/$APP_NAME" 2>/dev/null || true + open -a "/Applications/$APP_NAME" +} + +install_remote() { + local host="$1" + echo "==> Installing on $host" + + local remote_arch + remote_arch="$(ssh "$host" 'uname -m')" + if [[ "$remote_arch" != "$LOCAL_ARCH" ]]; then + echo " skipped: $host is $remote_arch, this build is $LOCAL_ARCH" >&2 + return 1 + fi + + # Quit it there first, or rsync replaces a bundle that is still running. + ssh "$host" "osascript -e 'tell application \"Scratch\" to quit' >/dev/null 2>&1 || true + while pgrep -f '/Applications/$APP_NAME' >/dev/null; do sleep 1; done + rm -rf '/Applications/$APP_NAME'" + + rsync -a --delete "$BUILT_APP" "$host:/Applications/" + ssh "$host" "xattr -dr com.apple.quarantine '/Applications/$APP_NAME' 2>/dev/null || true" +} + +install_local +failed=() +for host in ${hosts[@]+"${hosts[@]}"}; do + install_remote "$host" || failed+=("$host") +done + +version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "/Applications/$APP_NAME/Contents/Info.plist")" +echo "==> Scratch $version installed on this Mac${hosts[*]+ and: ${hosts[*]}}" +if [[ -n "${failed[*]:-}" ]]; then + echo "==> Failed: ${failed[*]}" >&2 + exit 1 +fi diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..cc9bc2d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,16 @@ use tokio::io::AsyncWriteExt; mod git; +/// A folder in the sidebar backed by a tag instead of a directory. +/// +/// `name` is what the sidebar shows and is always a single path segment; +/// `tag` may contain `/` for nested tags such as `area/finance`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartFolder { + pub name: String, + pub tag: String, +} + // Note metadata for list display #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NoteMetadata { @@ -25,6 +35,10 @@ pub struct NoteMetadata { pub title: String, pub preview: String, pub modified: i64, + /// Frontmatter tags, so the sidebar can resolve smart folders without + /// re-reading files or making a second round trip. + #[serde(default)] + pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -127,8 +141,14 @@ pub struct Settings { pub sidebar_width_px: Option, #[serde(rename = "ollamaModel")] pub ollama_model: Option, + /// Model chosen per AI harness, keyed by provider id ("claude", "codex", ...). + #[serde(rename = "aiModels")] + pub ai_models: Option>, #[serde(rename = "foldersEnabled")] pub folders_enabled: Option, + /// Folders whose contents are a live tag query rather than files on disk. + #[serde(rename = "smartFolders")] + pub smart_folders: Option>, #[serde(rename = "ignoredPatterns")] pub ignored_patterns: Option>, #[serde(rename = "customColorsLight")] @@ -172,6 +192,7 @@ pub struct SearchIndex { id_field: Field, title_field: Field, content_field: Field, + tags_field: Field, modified_field: Field, } @@ -182,13 +203,28 @@ impl SearchIndex { let id_field = schema_builder.add_text_field("id", STRING | STORED); let title_field = schema_builder.add_text_field("title", TEXT | STORED); let content_field = schema_builder.add_text_field("content", TEXT | STORED); + // Tags are matched exactly, not tokenized, so `tag:` queries are precise. + let tags_field = schema_builder.add_text_field("tags", STRING | STORED); let modified_field = schema_builder.add_i64_field("modified", INDEXED | STORED); let schema = schema_builder.build(); - // Create or open index + // Create or open index. + // + // An index written by an older build has a different schema (no `tags` + // field), and field handles are positional — reusing it would write to + // the wrong column. Discard and recreate on any schema mismatch; the + // caller rebuilds from the notes folder immediately afterwards, so + // nothing is lost but the time to reindex. std::fs::create_dir_all(index_path)?; - let index = Index::create_in_dir(index_path, schema.clone()) - .or_else(|_| Index::open_in_dir(index_path))?; + let index = match Index::open_in_dir(index_path) { + Ok(existing) if existing.schema() == schema => existing, + Ok(_) => { + std::fs::remove_dir_all(index_path)?; + std::fs::create_dir_all(index_path)?; + Index::create_in_dir(index_path, schema.clone())? + } + Err(_) => Index::create_in_dir(index_path, schema.clone())?, + }; let reader = index .reader_builder() @@ -205,6 +241,7 @@ impl SearchIndex { id_field, title_field, content_field, + tags_field, modified_field, }) } @@ -217,12 +254,16 @@ impl SearchIndex { writer.delete_term(id_term); // Add new document - writer.add_document(doc!( + let mut document = doc!( self.id_field => id, self.title_field => title, self.content_field => content, self.modified_field => modified, - ))?; + ); + for tag in extract_tags(content) { + document.add_text(self.tags_field, &tag); + } + writer.add_document(document)?; writer.commit()?; Ok(()) @@ -237,6 +278,17 @@ impl SearchIndex { } fn search(&self, query_str: &str, limit: usize) -> Result> { + // `tag:foo` (or `#foo`) is an exact tag lookup, not a full-text search. + if let Some(tag) = query_str + .strip_prefix("tag:") + .or_else(|| query_str.strip_prefix('#')) + { + let tag = tag.trim().trim_matches('"'); + if !tag.is_empty() { + return self.search_by_tag(tag, limit); + } + } + let searcher = self.reader.searcher(); let query_parser = QueryParser::for_index(&self.index, vec![self.title_field, self.content_field]); @@ -288,6 +340,74 @@ impl SearchIndex { Ok(results) } + /// Exact-match lookup of every note carrying `tag`, newest first. + fn search_by_tag(&self, tag: &str, limit: usize) -> Result> { + let searcher = self.reader.searcher(); + let term = tantivy::Term::from_field_text(self.tags_field, tag); + let query = tantivy::query::TermQuery::new(term, IndexRecordOption::Basic); + let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?; + + let mut results = Vec::with_capacity(top_docs.len()); + for (_, doc_address) in top_docs { + let doc: TantivyDocument = searcher.doc(doc_address)?; + let content = doc + .get_first(self.content_field) + .and_then(|v| v.as_str()) + .unwrap_or(""); + results.push(SearchResult { + id: doc + .get_first(self.id_field) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + title: doc + .get_first(self.title_field) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + preview: generate_preview(content), + modified: doc + .get_first(self.modified_field) + .and_then(|v| v.as_i64()) + .unwrap_or(0), + score: 1.0, + }); + } + // `modified` is not a fast field, so order after collection. + results.sort_by_key(|a| std::cmp::Reverse(a.modified)); + Ok(results) + } + + /// Every tag in the vault with the number of notes carrying it, + /// ordered by count descending then alphabetically. + fn all_tags(&self) -> Result> { + let searcher = self.reader.searcher(); + let mut counts: HashMap = HashMap::new(); + + for segment_reader in searcher.segment_readers() { + let store = segment_reader.get_store_reader(0)?; + let alive = segment_reader.alive_bitset(); + for doc_id in 0..segment_reader.max_doc() { + if alive.map(|bits| !bits.is_alive(doc_id)).unwrap_or(false) { + continue; + } + let doc: TantivyDocument = store.get(doc_id)?; + for value in doc.get_all(self.tags_field) { + if let Some(tag) = value.as_str() { + *counts.entry(tag.to_string()).or_insert(0) += 1; + } + } + } + } + + let mut tags: Vec = counts + .into_iter() + .map(|(tag, count)| TagCount { tag, count }) + .collect(); + tags.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag))); + Ok(tags) + } + fn rebuild_index(&self, notes_folder: &PathBuf, ignored_dirs: &[String]) -> Result<()> { let mut writer = self.writer.lock().expect("search writer mutex"); writer.delete_all_documents()?; @@ -316,12 +436,16 @@ impl SearchIndex { let title = extract_title(&content); - writer.add_document(doc!( + let mut document = doc!( self.id_field => id.as_str(), self.title_field => title, self.content_field => content.as_str(), self.modified_field => modified, - ))?; + ); + for tag in extract_tags(&content) { + document.add_text(self.tags_field, &tag); + } + writer.add_document(document)?; } } } @@ -332,6 +456,12 @@ impl SearchIndex { } } +#[derive(Debug, Serialize, Clone)] +pub struct TagCount { + pub tag: String, + pub count: usize, +} + // App state with improved structure pub struct AppState { pub app_config: RwLock, // notes_folder path (stored in app data) @@ -468,6 +598,77 @@ fn strip_frontmatter(content: &str) -> &str { content } +/// Extract a note's tags from its YAML frontmatter `tags:` key. +/// +/// Supports flow style (`tags: [a, b]`), block style (`tags:` followed by +/// indented `- a` items) and a bare scalar. Values are returned in file order, +/// deduplicated, with surrounding quotes and a leading `#` removed. +/// +/// Frontmatter is the only source. A `#word` in the body is prose, not +/// metadata — notes carrying `Type: #type/meeting` header lines are +/// unmigrated and should be fixed at the source rather than parsed here. +fn extract_tags(content: &str) -> Vec { + let mut tags = extract_frontmatter_tags(content); + let mut seen = std::collections::HashSet::new(); + tags.retain(|tag| seen.insert(tag.clone())); + tags +} + +/// Read the `tags:` key out of a note's YAML frontmatter, if it has any. +fn extract_frontmatter_tags(content: &str) -> Vec { + let trimmed = content.trim_start(); + let Some(rest) = trimmed.strip_prefix("---") else { + return Vec::new(); + }; + let Some(end) = rest.find("\n---") else { + return Vec::new(); + }; + let frontmatter = &rest[..end]; + + let clean = |raw: &str| -> Option { + let value = raw + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .trim_start_matches('#') + .trim(); + if value.is_empty() || value == "[]" || value.ends_with('/') { + None + } else { + Some(value.to_string()) + } + }; + + let mut tags = Vec::new(); + let mut lines = frontmatter.lines(); + while let Some(line) = lines.next() { + let Some(value) = line.strip_prefix("tags:") else { + continue; + }; + let value = value.trim(); + if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { + // Flow style: tags: [a, b] + tags.extend(inner.split(',').filter_map(clean)); + } else if !value.is_empty() { + // Single scalar: tags: work + tags.extend(clean(value)); + } else { + // Block style: subsequent indented `- item` lines + for next in lines.by_ref() { + let indented = next.starts_with(' ') || next.starts_with('\t'); + let Some(item) = next.trim_start().strip_prefix("- ") else { + break; + }; + if !indented { + break; + } + tags.extend(clean(item)); + } + } + break; + } + tags +} + // Utility: Extract title from markdown content fn extract_title(content: &str) -> String { let body = strip_frontmatter(content); @@ -631,11 +832,31 @@ fn get_effective_ignored_dirs(settings: &Settings) -> Vec { }) } -/// Filter for WalkDir: skips excluded and user-ignored directories. +/// Agent instruction files. They live alongside notes and are worth searching, +/// but they are tooling config rather than notes, so the note list hides them. +const AGENT_DOC_STEMS: &[&str] = &["AGENTS", "CLAUDE", "GEMINI", "COPILOT-INSTRUCTIONS"]; + +/// True for a note ID whose file is an agent instruction file, at any depth. +fn is_agent_doc_id(id: &str) -> bool { + let stem = id.rsplit('/').next().unwrap_or(id); + AGENT_DOC_STEMS + .iter() + .any(|name| stem.eq_ignore_ascii_case(name)) +} + +/// True for tool config directories (`.claude`, `.agents`, `.cursor`, …). Every +/// dot-directory is config by convention, so the rule covers future tools too. +fn is_config_dir(name: &str) -> bool { + name.starts_with('.') && name != "." +} + +/// Filter for WalkDir: skips excluded, config and user-ignored directories. fn is_visible_notes_entry(entry: &walkdir::DirEntry, ignored_dirs: &[String]) -> bool { if entry.file_type().is_dir() { let name = entry.file_name().to_str().unwrap_or(""); - return !EXCLUDED_DIRS.contains(&name) && !ignored_dirs.iter().any(|d| d == name); + return !EXCLUDED_DIRS.contains(&name) + && !is_config_dir(name) + && !ignored_dirs.iter().any(|d| d == name); } true } @@ -650,7 +871,10 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] for component in rel.parent().unwrap_or(Path::new("")).components() { if let std::path::Component::Normal(name) = component { let name_str = name.to_str()?; - if EXCLUDED_DIRS.contains(&name_str) || ignored_dirs.iter().any(|d| d == name_str) { + if EXCLUDED_DIRS.contains(&name_str) + || is_config_dir(name_str) + || ignored_dirs.iter().any(|d| d == name_str) + { return None; } } @@ -911,7 +1135,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let path_clone = path.clone(); let discovered = tokio::task::spawn_blocking(move || { use walkdir::WalkDir; - let mut results: Vec<(String, String, String, i64)> = Vec::new(); + let mut results: Vec<(String, String, String, i64, Vec)> = Vec::new(); for entry in WalkDir::new(&path_clone) .max_depth(10) .into_iter() @@ -933,7 +1157,8 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str .unwrap_or(0); let title = extract_title(&content); let preview = generate_preview(&content); - results.push((id, title, preview, modified)); + let tags = extract_tags(&content); + results.push((id, title, preview, modified, tags)); } } } @@ -944,11 +1169,12 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let mut notes: Vec = discovered .into_iter() - .map(|(id, title, preview, modified)| NoteMetadata { + .map(|(id, title, preview, modified, tags)| NoteMetadata { id, title, preview, modified, + tags, }) .collect(); @@ -983,6 +1209,10 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str } } + // Agent instruction files stay in the cache and the search index — they are + // findable and openable — but they don't belong in the list of notes. + notes.retain(|note| !is_agent_doc_id(¬e.id)); + Ok(notes) } @@ -2001,6 +2231,7 @@ async fn import_file_to_folder( title: extracted_title, preview, modified, + tags: extract_tags(&content), }; // Update notes cache so fallback search sees the imported note immediately @@ -2018,6 +2249,16 @@ async fn import_file_to_folder( Ok(metadata) } +/// Every tag in the vault with its note count, for the sidebar tag pane. +#[tauri::command] +async fn list_tags(state: State<'_, AppState>) -> Result, String> { + let index = state.search_index.lock().expect("search index mutex"); + match (*index).as_ref() { + Some(search_index) => search_index.all_tags().map_err(|e| e.to_string()), + None => Ok(vec![]), + } +} + #[tauri::command] async fn search_notes(query: String, state: State<'_, AppState>) -> Result, String> { let trimmed_query = query.trim().to_string(); @@ -3013,6 +3254,15 @@ async fn ai_check_opencode_cli() -> Result { /// Shared AI CLI execution: spawns `command` with `args`, writes `stdin_input` to stdin, /// and returns the result with a 5-minute timeout. +// One raw stdout line from a running agent CLI. Lines are forwarded verbatim; +// the frontend owns provider-specific parsing so this stays provider-agnostic. +#[derive(Clone, Serialize)] +struct AiStreamLine { + run_id: String, + line: String, +} + +#[allow(clippy::too_many_arguments)] async fn execute_ai_cli( cli_name: &str, command: String, @@ -3021,6 +3271,7 @@ async fn execute_ai_cli( not_found_msg: String, current_dir: Option, extra_env: Option>, + stream: Option<(AppHandle, String)>, ) -> Result { use std::io::Write; use std::process::{Child, Stdio}; @@ -3140,17 +3391,42 @@ async fn execute_ai_cli( .ok() .and_then(|mut g| g.as_mut().and_then(|p| p.stderr.take())); - use std::io::Read; + use std::io::{BufRead, BufReader, Read}; + // Drain stderr on its own thread. Streaming makes long runs normal, and a + // CLI that fills the stderr pipe while we are still reading stdout would + // block mid-write and deadlock until the timeout fires. + let stderr_reader = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut err) = stderr_handle { + let _ = err.read_to_string(&mut buf); + } + buf + }); + + // Read stdout a line at a time so the frontend can render progress while + // the CLI is still running. The full text is still accumulated, so the + // returned AiExecutionResult is unchanged for non-streaming callers. let mut stdout_str = String::new(); - if let Some(mut out) = stdout_handle { - let _ = out.read_to_string(&mut stdout_str); + if let Some(out) = stdout_handle { + let reader = BufReader::new(out); + for line in reader.lines() { + let Ok(line) = line else { break }; + if let Some((app, run_id)) = &stream { + let _ = app.emit( + "ai-stream-line", + AiStreamLine { + run_id: run_id.clone(), + line: line.clone(), + }, + ); + } + stdout_str.push_str(&line); + stdout_str.push('\n'); + } } - let mut stderr_str = String::new(); - if let Some(mut err) = stderr_handle { - let _ = err.read_to_string(&mut stderr_str); - } + let stderr_str = stderr_reader.join().unwrap_or_default(); // Collect exit status — process has exited after stdout/stderr close let success = child_for_task @@ -3222,10 +3498,110 @@ async fn execute_ai_cli( Ok(result) } +/// Tools a note-editing agent is allowed to use without prompting. Bash and +/// anything else destructive is deliberately absent: these runs are +/// non-interactive, so a prompt would be auto-denied rather than shown. +const AI_ALLOWED_TOOLS: &str = + "Read Edit Write Glob Grep WebFetch WebSearch TodoWrite"; + +/// MCP server names configured for the Claude CLI: user scope, this folder's +/// project scope, and a `.mcp.json` in the notes folder. Claude rejects +/// wildcard allow rules, so each server has to be named to be usable in a +/// headless run. +fn claude_mcp_servers(notes_folder: &str) -> Vec { + fn server_names(value: &serde_json::Value) -> Vec { + value + .get("mcpServers") + .and_then(|servers| servers.as_object()) + .map(|servers| servers.keys().cloned().collect()) + .unwrap_or_default() + } + + fn read_json(path: PathBuf) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() + } + + let mut names: Vec = Vec::new(); + + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + if let Some(config) = read_json(home.join(".claude.json")) { + names.extend(server_names(&config)); + if let Some(project) = config.get("projects").and_then(|p| p.get(notes_folder)) { + names.extend(server_names(project)); + } + } + } + + if let Some(project_config) = read_json(PathBuf::from(notes_folder).join(".mcp.json")) { + names.extend(server_names(&project_config)); + } + + names.sort(); + names.dedup(); + names +} + +/// The contract every harness gets alongside the user's instruction. Runs are +/// headless, so the agent has to decide rather than ask. +fn note_agent_instructions(file_path: &str) -> String { + format!( + "You are a thinking partner for one Markdown note: {file_path}\n\ + \n\ + Decide first whether the message is a question or an instruction to \ + change the note. A question — \"what are\", \"how much\", \"should \ + we\", asking for options, opinions or a comparison — is answered in \ + your reply and nothing is written to the file. Only edit when asked \ + to: add, write, put, update, rewrite, clean up, extract, summarise \ + into the note, and so on. When in doubt, answer and offer to write \ + it down.\n\ + \n\ + Answer from real sources, not memory. For anything about the world — \ + places, neighbourhoods, prices, products, rules, availability — use \ + WebSearch and WebFetch first, and say what you found. Read the note \ + for context before answering; it holds the thread between turns, \ + since each run starts fresh with no memory of earlier ones.\n\ + \n\ + When you do edit, change only this file. Never create, delete, \ + rename, or modify any other file.\n\ + \n\ + You are running non-interactively: never ask follow-up questions and \ + never ask the user to grant permissions or paste content. If an \ + instruction is ambiguous, take the most reasonable reading, act on \ + it, and say which reading you took. Close with one or two sentences \ + saying what you changed, or, if you only answered, nothing further." + ) +} + +/// Reasoning depths the CLIs accept. Values from the frontend are matched +/// against this list so nothing unexpected reaches a command line. +const AI_EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; + +fn effort_level(effort: &Option) -> Option<&'static str> { + let requested = effort.as_deref()?.trim(); + AI_EFFORT_LEVELS + .iter() + .find(|level| level.eq_ignore_ascii_case(requested)) + .copied() +} + +/// Appends `--model ` when the caller picked one; an empty or missing +/// value leaves the CLI on its own default. +fn push_model_flag(args: &mut Vec, model: &Option) { + if let Some(name) = model.as_deref().map(str::trim).filter(|n| !n.is_empty()) { + args.push("--model".to_string()); + args.push(name.to_string()); + } +} + #[tauri::command] async fn ai_execute_claude( + app: AppHandle, file_path: String, prompt: String, + run_id: Option, + model: Option, + effort: Option, state: State<'_, AppState>, ) -> Result { let folder = { @@ -3247,45 +3623,89 @@ async fn ai_execute_claude( return Err("File must be within notes folder".to_string()); } + let mut args = vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--permission-mode".to_string(), + "acceptEdits".to_string(), + "--permission-prompts".to_string(), + "none".to_string(), + "--allowedTools".to_string(), + std::iter::once(AI_ALLOWED_TOOLS.to_string()) + .chain( + claude_mcp_servers(&folder) + .into_iter() + .map(|server| format!("mcp__{server}")), + ) + .collect::>() + .join(" "), + "--append-system-prompt".to_string(), + note_agent_instructions(&canonical.to_string_lossy()), + ]; + push_model_flag(&mut args, &model); + if let Some(level) = effort_level(&effort) { + args.push("--effort".to_string()); + args.push(level.to_string()); + } + execute_ai_cli( "Claude", "claude".to_string(), - vec![ - canonical.to_string_lossy().to_string(), - "--dangerously-skip-permissions".to_string(), - "--print".to_string(), - ], + args, prompt, "Claude CLI not found. Please install it from https://claude.ai/code".to_string(), + Some(folder), None, - None, + run_id.map(|id| (app, id)), ) .await } #[tauri::command] -async fn ai_execute_codex(file_path: String, prompt: String) -> Result { +async fn ai_execute_codex( + app: AppHandle, + file_path: String, + prompt: String, + run_id: Option, + model: Option, + effort: 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 stdin_input = format!( - "Edit only this markdown file: {file_path}\n\ - Apply the user's instructions below directly to that file.\n\ - Do not create, delete, rename, or modify any other files.\n\ - User instructions:\n\ - {prompt}" + "{}\n\nUser instructions:\n{prompt}", + note_agent_instructions(&file_path) ); + let mut args = vec![ + "exec".to_string(), + "--json".to_string(), + "--skip-git-repo-check".to_string(), + "--sandbox".to_string(), + "workspace-write".to_string(), + ]; + push_model_flag(&mut args, &model); + // Codex has no effort flag; it reads the same value from its config. + if let Some(level) = effort_level(&effort) { + args.push("-c".to_string()); + args.push(format!("model_reasoning_effort=\"{level}\"")); + } + args.push("-".to_string()); + execute_ai_cli( "Codex", "codex".to_string(), - vec![ - "exec".to_string(), - "--skip-git-repo-check".to_string(), - "--dangerously-bypass-approvals-and-sandbox".to_string(), - "-".to_string(), - ], + args, stdin_input, "Codex CLI not found. Please install it from https://github.com/openai/codex".to_string(), + Some(folder), None, - None, + run_id.map(|id| (app, id)), ) .await } @@ -3294,6 +3714,7 @@ async fn ai_execute_codex(file_path: String, prompt: String) -> Result, state: State<'_, AppState>, ) -> Result { let folder = { @@ -3316,23 +3737,24 @@ async fn ai_execute_opencode( } let run_prompt = format!( - "Edit the attached markdown file in place.\n\ - Do not create, delete, rename, or modify any other files.\n\ - User instructions:\n\ - {}", + "{}\n\nUser instructions:\n{}", + note_agent_instructions(&canonical.to_string_lossy()), prompt ); + let mut args = vec![ + "run".to_string(), + "--file".to_string(), + canonical.to_string_lossy().to_string(), + ]; + push_model_flag(&mut args, &model); + args.push("--".to_string()); + args.push(run_prompt); + execute_ai_cli( "OpenCode", "opencode".to_string(), - vec![ - "run".to_string(), - "--file".to_string(), - canonical.to_string_lossy().to_string(), - "--".to_string(), - run_prompt, - ], + args, String::new(), "OpenCode CLI not found. Please install it from https://opencode.ai".to_string(), Some(notes_root.to_string_lossy().to_string()), @@ -3342,6 +3764,7 @@ async fn ai_execute_opencode( r#"{"*":"allow","bash":"deny","task":"deny","webfetch":"deny","websearch":"deny","codesearch":"deny","skill":"deny","external_directory":"deny","doom_loop":"deny"}"#.to_string(), ), ]), + None, ) .await } @@ -3442,6 +3865,7 @@ async fn ai_execute_ollama( "Ollama CLI not found. Please install it from https://ollama.com".to_string(), None, None, + None, ) .await?; @@ -3857,6 +4281,7 @@ pub fn run() { preview_note_name, write_file, search_notes, + list_tags, start_file_watcher, rebuild_search_index, get_default_ignored_patterns, @@ -3994,3 +4419,84 @@ fn set_title_bar_theme( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{extract_tags, is_agent_doc_id, is_config_dir}; + + #[test] + fn finds_mcp_servers_configured_for_claude() { + // The user's own config is the source; an unconfigured folder adds none. + let servers = super::claude_mcp_servers("/nonexistent-notes-folder"); + assert!(servers.iter().all(|name| !name.is_empty())); + assert_eq!( + servers.len(), + servers + .iter() + .collect::>() + .len(), + "server names must be deduplicated" + ); + } + + #[test] + fn hides_agent_docs_at_any_depth() { + assert!(is_agent_doc_id("AGENTS")); + assert!(is_agent_doc_id("projects/scratch/CLAUDE")); + assert!(is_agent_doc_id("claude")); // filenames vary in case + assert!(!is_agent_doc_id("agents-meeting")); + assert!(!is_agent_doc_id("notes/claude-api-ideas")); + } + + #[test] + fn treats_dot_directories_as_tool_config() { + assert!(is_config_dir(".claude")); + assert!(is_config_dir(".agents")); + assert!(!is_config_dir("agents")); + assert!(!is_config_dir(".")); + } + + #[test] + fn parses_flow_style_tags() { + let note = "---\ntags: [reference, finance]\n---\n\n# Title\n"; + assert_eq!(extract_tags(note), vec!["reference", "finance"]); + } + + #[test] + fn parses_block_style_tags() { + let note = "---\ntags:\n - work\n - \"hub\"\n---\n\n# Title\n"; + assert_eq!(extract_tags(note), vec!["work", "hub"]); + } + + #[test] + fn parses_scalar_and_strips_leading_hash() { + assert_eq!(extract_tags("---\ntags: #work\n---\n"), vec!["work"]); + } + + #[test] + fn ignores_notes_without_any_tags() { + assert!(extract_tags("# Title\n\nJust prose.\n").is_empty()); + } + + #[test] + fn ignores_body_hashtags_and_legacy_header_tags() { + // Frontmatter is the only source of truth. A note still carrying + // `Type: #type/meeting` header lines is unmigrated, not tagged. + let legacy = "Type: #type/meeting\nArea: #area/business\n\nSee #finance too.\n"; + assert!(extract_tags(legacy).is_empty()); + + let tagged = "---\ntags: [meeting]\n---\n\nSee #finance too.\n"; + assert_eq!(extract_tags(tagged), vec!["meeting"]); + } + + #[test] + fn handles_empty_tag_list() { + assert!(extract_tags("---\ntags: []\n---\n").is_empty()); + } + + #[test] + fn stops_block_list_at_next_key() { + let note = "---\ntags:\n - work\nstatus: active\n---\n"; + assert_eq!(extract_tags(note), vec!["work"]); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 11a19618..d8a0ea30 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -67,7 +67,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE3RTQ2NTA4QzJGODdFRTYKUldUbWZ2akNDR1hrcDlud3VQSVhuYVU0cDk5V0RkaVFuVElRSGRVMjlFam9IdFVCNnU1ZlVJOXEK", "endpoints": [ - "https://github.com/erictli/scratch/releases/latest/download/latest.json" + "https://github.com/multiplehats/scratch/releases/latest/download/latest.json" ] } } diff --git a/src/App.css b/src/App.css index d094bedf..2a4e7989 100644 --- a/src/App.css +++ b/src/App.css @@ -16,6 +16,8 @@ --color-border: rgba(28, 25, 23, 0.08); --color-accent: #1c1917; --color-selection: rgba(250, 204, 21, 0.4); /* Tailwind yellow-400 */ + /* Scrim behind dialogs: always a dim, never a wash, like macOS sheets. */ + --color-overlay: rgba(28, 25, 23, 0.45); /* Editor font settings - simplified (overridden by ThemeContext) */ --editor-font-family: @@ -61,6 +63,7 @@ --color-border: rgba(250, 249, 249, 0.07); --color-accent: #fafaf9; --color-selection: rgba(253, 224, 71, 0.35); /* Tailwind yellow-300 */ + --color-overlay: rgba(0, 0, 0, 0.6); /* Syntax highlighting colors - Dark (GitHub) */ --color-syntax-keyword: #ea4a5a; @@ -111,6 +114,9 @@ /* Accent colors */ --color-accent: var(--color-accent); + + /* Dialog scrim */ + --color-overlay: var(--color-overlay); } /* Base styles */ @@ -164,6 +170,32 @@ body { display: none; /* Chrome, Safari, Opera */ } +/* Minimal scrollbars: thin, subtle, and out of the way */ +.scrollbar-minimal::-webkit-scrollbar { + width: 7px; + height: 7px; +} + +.scrollbar-minimal::-webkit-scrollbar-track, +.scrollbar-minimal::-webkit-scrollbar-corner { + background: transparent; +} + +.scrollbar-minimal::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--color-text-muted) 22%, transparent); + border: 2px solid transparent; + background-clip: content-box; + border-radius: 999px; +} + +.scrollbar-minimal:hover::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--color-text-muted) 38%, transparent); +} + +.scrollbar-minimal::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--color-text-muted) 55%, transparent); +} + /* Keep layout width stable when vertical scrollbar appears */ .scrollbar-gutter-stable { scrollbar-gutter: stable; @@ -1302,3 +1334,24 @@ table.not-prose th { pointer-events: auto; } } + +/* + * Fix: Radix popovers land in the wrong place at non-100% interface zoom. + * + * Interface zoom is `zoom` on , so every coordinate inside the document + * is scaled. Radix positions menus, dropdowns and tooltips from the pointer's + * clientX/clientY, which are physical viewport pixels, then writes them as a + * translate inside that scaled space — so the offset is multiplied by the zoom + * factor again and the menu opens away from the cursor (visibly so by 130%). + * + * The popper wrapper only positions; the element inside it does the drawing. + * So undo the zoom on the wrapper, putting its translate back into physical + * pixels, and re-apply it to the content so the menu still looks right. + */ +[data-radix-popper-content-wrapper] { + zoom: calc(1 / var(--interface-zoom, 1)); +} + +[data-radix-popper-content-wrapper] > * { + zoom: var(--interface-zoom, 1); +} diff --git a/src/App.tsx b/src/App.tsx index 0e5224e9..fc16cf66 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { OllamaIcon, } from "./components/icons"; import { AiEditModal } from "./components/ai/AiEditModal"; +import { AiSidebar } from "./components/ai/AiSidebar"; import { AiResponseToast } from "./components/ai/AiResponseToast"; import { KeyboardShortcutsModal } from "./components/shortcuts/KeyboardShortcutsModal"; import { PreviewApp } from "./components/preview/PreviewApp"; @@ -49,6 +50,8 @@ function getWindowMode(): { type ViewState = "notes" | "settings"; +type AiRunTarget = "modal" | "sidebar"; + function AppContent() { const { notesFolder, @@ -73,6 +76,7 @@ function AppContent() { const [view, setView] = useState("notes"); const [sidebarVisible, setSidebarVisible] = useState(true); const [aiModalOpen, setAiModalOpen] = useState(false); + const [aiSidebarOpen, setAiSidebarOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [aiEditing, setAiEditing] = useState(false); const [focusMode, setFocusMode] = useState(false); @@ -127,12 +131,19 @@ function AppContent() { setPaletteOpen(true); }, []); - // AI Edit handler + // Where an AI run's result is surfaced: the modal hands off to a toast, + // the sidebar renders it inline in its transcript. const handleAiEdit = useCallback( - async (prompt: string, ollamaModel?: string) => { + async ( + prompt: string, + model?: string, + effort?: aiService.AiEffort, + target: AiRunTarget = "modal", + runId?: string, + ): Promise => { if (!currentNote) { toast.error("No note selected"); - return; + return null; } setAiEditing(true); @@ -140,22 +151,43 @@ function AppContent() { try { let result: aiService.AiExecutionResult; if (aiProvider === "codex") { - result = await aiService.executeCodexEdit(currentNote.path, prompt); + result = await aiService.executeCodexEdit( + currentNote.path, + prompt, + runId, + model, + effort, + ); } else if (aiProvider === "opencode") { - result = await aiService.executeOpenCodeEdit(currentNote.path, prompt); + result = await aiService.executeOpenCodeEdit( + currentNote.path, + prompt, + model, + ); } else if (aiProvider === "ollama") { result = await aiService.executeOllamaEdit( currentNote.path, prompt, - ollamaModel || "qwen3:8b", + model || aiService.OLLAMA_FALLBACK_MODEL, ); } else { - result = await aiService.executeClaudeEdit(currentNote.path, prompt); + result = await aiService.executeClaudeEdit( + currentNote.path, + prompt, + runId, + model, + effort, + ); } // Reload the current note from disk await reloadCurrentNote(); + // The sidebar renders its own transcript; only the modal hands off to a toast. + if (target === "sidebar") { + return result; + } + // Show results if (result.success) { // Close modal after success @@ -179,11 +211,16 @@ function AppContent() { { duration: Infinity, closeButton: true }, ); } + return result; } catch (error) { console.error("[AI] Error:", error); - toast.error( - `Error: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + const message = + error instanceof Error ? error.message : "Unknown error"; + if (target === "sidebar") { + return { success: false, output: "", error: message }; + } + toast.error(`Error: ${message}`); + return null; } finally { setAiEditing(false); } @@ -191,6 +228,16 @@ function AppContent() { [aiProvider, currentNote, reloadCurrentNote], ); + const handleAiSidebarExecute = useCallback( + ( + prompt: string, + runId: string, + model: string | undefined, + effort: aiService.AiEffort | undefined, + ) => handleAiEdit(prompt, model, effort, "sidebar", runId), + [handleAiEdit], + ); + // Memoize display items to prevent unnecessary recalculations const displayItems = useMemo(() => { return searchQuery.trim() ? searchResults : notes; @@ -251,6 +298,18 @@ function AppContent() { return; } + // Cmd+Shift+A - Toggle the AI assistant sidebar + if ( + (e.metaKey || e.ctrlKey) && + e.shiftKey && + e.key.toLowerCase() === "a" && + currentNoteRef.current + ) { + e.preventDefault(); + setAiSidebarOpen((open) => !open); + return; + } + // Cmd+Shift+M - Toggle markdown source mode if ( (e.metaKey || e.ctrlKey) && @@ -483,12 +542,24 @@ function AppContent() { setAiSidebarOpen((open) => !open)} + aiSidebarOpen={aiSidebarOpen && !focusMode} sidebarVisible={sidebarVisible} focusMode={focusMode} onEditorReady={(editor) => { editorRef.current = editor; }} /> + setAiSidebarOpen(false)} + onExecute={handleAiSidebarExecute} + isExecuting={aiEditing} + /> )} @@ -496,7 +567,7 @@ function AppContent() { {/* Shared backdrop for command palette and AI modal */} {(paletteOpen || aiModalOpen) && (
{ if (paletteOpen) handleClosePalette(); if (aiModalOpen) setAiModalOpen(false); @@ -530,8 +601,8 @@ function AppContent() { isExecuting={aiEditing} /> - {/* AI Editing Overlay */} - {aiEditing && ( + {/* AI Editing Overlay — modal runs only; the sidebar shows progress inline */} + {aiEditing && aiModalOpen && (
{aiProvider === "codex" ? ( diff --git a/src/components/ai/AiEditModal.tsx b/src/components/ai/AiEditModal.tsx index b9233f03..538d19e2 100644 --- a/src/components/ai/AiEditModal.tsx +++ b/src/components/ai/AiEditModal.tsx @@ -15,7 +15,7 @@ interface AiEditModalProps { open: boolean; provider: AiProvider; onBack: () => void; // Go back to command palette - onExecute: (prompt: string, ollamaModel?: string) => Promise; + onExecute: (prompt: string, ollamaModel?: string) => Promise; isExecuting: boolean; } diff --git a/src/components/ai/AiResponseToast.tsx b/src/components/ai/AiResponseToast.tsx index 7fb5a063..b21d6281 100644 --- a/src/components/ai/AiResponseToast.tsx +++ b/src/components/ai/AiResponseToast.tsx @@ -1,221 +1,13 @@ import { ClaudeIcon, CodexIcon, OpenCodeIcon, OllamaIcon } from "../icons"; import { mod } from "../../lib/platform"; import type { AiProvider } from "../../services/ai"; -import { CodeCopyButton } from "../ui"; +import { parseMarkdown } from "./markdown"; interface AiResponseToastProps { output: string; provider: AiProvider; } -// Simple markdown-to-React converter for basic formatting -function parseMarkdown(text: string): React.ReactNode { - const lines = text.split("\n"); - const elements: React.ReactNode[] = []; - let inCodeBlock = false; - let codeBlockContent: string[] = []; - let listItems: string[] = []; - let listType: "ul" | "ol" | null = null; - - const flushList = (index: number) => { - if (listItems.length > 0) { - const ListTag = listType === "ol" ? "ol" : "ul"; - elements.push( - - {listItems.map((item, i) => ( -
  • - {parseInlineMarkdown(item)} -
  • - ))} -
    , - ); - listItems = []; - listType = null; - } - }; - - lines.forEach((line, index) => { - // Code blocks - if (line.trim().startsWith("```")) { - if (inCodeBlock) { - const codeText = codeBlockContent.join("\n"); - elements.push( -
    -
    - -
    -
    -              {codeText}
    -            
    -
    , - ); - codeBlockContent = []; - inCodeBlock = false; - } else { - flushList(index); - inCodeBlock = true; - } - return; - } - - if (inCodeBlock) { - codeBlockContent.push(line); - return; - } - - // Unordered list items - if (line.match(/^\s*[-*]\s+/)) { - if (listType !== "ul") { - flushList(index); - listType = "ul"; - } - listItems.push(line.replace(/^\s*[-*]\s+/, "")); - return; - } - - // Ordered list items - if (line.match(/^\s*\d+\.\s+/)) { - if (listType !== "ol") { - flushList(index); - listType = "ol"; - } - listItems.push(line.replace(/^\s*\d+\.\s+/, "")); - return; - } - - // Headers - render as bold text - const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headerMatch) { - flushList(index); - const headerText = headerMatch[2]; - elements.push( -

    - {parseInlineMarkdown(headerText)} -

    , - ); - return; - } - - // Regular line - flushList(index); - if (line.trim()) { - elements.push( -

    - {parseInlineMarkdown(line)} -

    , - ); - } else if (elements.length > 0) { - // Empty line adds spacing - elements.push(
    ); - } - }); - - // Flush any unclosed code block - if (inCodeBlock && codeBlockContent.length > 0) { - const codeText = codeBlockContent.join("\n"); - elements.push( -
    -
    - -
    -
    -          {codeText}
    -        
    -
    , - ); - } - - flushList(lines.length); - - return elements; -} - -function parseInlineMarkdown(text: string): React.ReactNode { - const parts: React.ReactNode[] = []; - let remaining = text; - let key = 0; - - // Order matters: code first (to avoid processing markdown inside code) - const patterns = [ - { - // Inline code: `code` - regex: /`([^`]+)`/g, - render: (match: string) => ( - - {match} - - ), - }, - { - // Bold: **text** or __text__ - regex: /(\*\*|__)(.+?)\1/g, - render: (match: string) => ( - - {match} - - ), - }, - { - // Italic: *text* or _text_ (but not ** or __) - regex: /(? ( - - {match} - - ), - }, - ]; - - patterns.forEach(({ regex, render }) => { - const newParts: React.ReactNode[] = []; - const currentParts = parts.length > 0 ? parts : [remaining]; - - currentParts.forEach((part) => { - if (typeof part !== "string") { - newParts.push(part); - return; - } - - let lastIndex = 0; - const matches = Array.from(part.matchAll(regex)); - - matches.forEach((match) => { - if (match.index! > lastIndex) { - newParts.push(part.slice(lastIndex, match.index)); - } - // Extract the captured group (content without markers) - const content = match[2] || match[1]; - newParts.push(render(content)); - lastIndex = match.index! + match[0].length; - }); - - if (lastIndex < part.length) { - newParts.push(part.slice(lastIndex)); - } - }); - - parts.splice(0, parts.length, ...newParts); - }); - - return parts.length > 0 ? parts : remaining; -} - export function AiResponseToast({ output, provider }: AiResponseToastProps) { const Icon = provider === "codex" @@ -231,7 +23,7 @@ export function AiResponseToast({ output, provider }: AiResponseToastProps) {
    AI Edit Complete
    -
    +
    {parseMarkdown(output)}
    diff --git a/src/components/ai/AiSidebar.tsx b/src/components/ai/AiSidebar.tsx new file mode 100644 index 00000000..b52fa2e9 --- /dev/null +++ b/src/components/ai/AiSidebar.tsx @@ -0,0 +1,554 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + ClaudeIcon, + CodexIcon, + OpenCodeIcon, + OllamaIcon, + XIcon, + CheckIcon, + ArrowUpIcon, + SpinnerIcon, +} from "../icons"; +import { + Button, + IconButton, + Input, + Select, + TextEffect, + TextScramble, + TextShimmer, + Tooltip, +} from "../ui"; +import type { SelectProps } from "../ui/Select"; +import { cn } from "../../lib/utils"; +import { + AI_PROVIDER_LABELS, + AI_PROVIDER_MODELS, + AI_PROVIDER_ORDER, + DEFAULT_AI_EFFORT, + DEFAULT_AI_MODELS, + supportsEffort, + getAvailableAiProviders, + type AiEffort, + type AiExecutionResult, + type AiProvider, +} from "../../services/ai"; +import type { Settings } from "../../types/note"; +import { + onAiStreamLine, + parseStreamLine, + supportsStreaming, + type AiStreamEvent, +} from "../../services/aiStream"; +import { parseMarkdown } from "./markdown"; + +interface AiSidebarProps { + open: boolean; + provider: AiProvider; + noteTitle?: string; + /** Identifies the open note, so the title only re-scrambles on a switch. */ + noteId?: string; + onProviderChange: (provider: AiProvider) => void; + onClose: () => void; + onExecute: ( + prompt: string, + runId: string, + model: string | undefined, + effort: AiEffort | undefined, + ) => Promise; + isExecuting: boolean; +} + +type RunStatus = "running" | "done" | "error"; + +// A step is one thing the agent did: a line of prose, or a tool call. +type Step = + | { kind: "text"; text: string } + | { kind: "tool"; name: string; detail: string }; + +interface Run { + id: string; + prompt: string; + provider: AiProvider; + model: string; + status: RunStatus; + steps: Step[]; + output: string; + error: string | null; +} + +const providerIcons: Record = { + claude: ClaudeIcon, + codex: CodexIcon, + opencode: OpenCodeIcon, + ollama: OllamaIcon, +}; + +function ToolStep({ step }: { step: { name: string; detail: string } }) { + const chip = ( +
    + {step.name} + {step.detail && ( + {step.detail} + )} +
    + ); + + return step.detail.length > 40 ? ( + {chip} + ) : ( + chip + ); +} + +// A bare spinner reads as "hung" on runs that can take minutes, so show +// elapsed time as proof the process is still alive. +function ElapsedTime() { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const started = Date.now(); + const id = window.setInterval( + () => setElapsed(Math.floor((Date.now() - started) / 1000)), + 1000, + ); + return () => window.clearInterval(id); + }, []); + + return {elapsed}s; +} + +// Each run is an independent CLI invocation with no memory of the previous +// one, so runs are shown as a timeline rather than a threaded conversation. +function RunCard({ run }: { run: Run }) { + const Icon = providerIcons[run.provider]; + const running = run.status === "running"; + + return ( +
    +
    + {run.prompt} +
    + +
    + +
    + {run.steps.map((step, index) => + step.kind === "tool" ? ( + + ) : ( +
    + {parseMarkdown(step.text)} +
    + ), + )} + + {running && ( +
    + + {`${AI_PROVIDER_LABELS[run.provider]}${ + run.model ? ` · ${run.model}` : "" + } is working…`} + + +
    + )} + + {run.status === "error" && ( +

    + {run.error || "Something went wrong"} +

    + )} + + {run.status === "done" && ( +
    + {run.steps.length === 0 && run.output.trim() ? ( + parseMarkdown(run.output) + ) : ( +
    + + Done +
    + )} +
    + )} +
    +
    +
    + ); +} + +// Compact in-composer dropdown: a native + {children} + +
    + ); +} + +// Sentinel option that swaps the model dropdown for a free-text field, so a +// model that postdates AI_PROVIDER_MODELS is still reachable. +const CUSTOM_MODEL_VALUE = "__custom__"; + +function defaultModelFor(provider: AiProvider): string { + return DEFAULT_AI_MODELS[provider]; +} + +export function AiSidebar({ + open, + provider, + noteTitle, + noteId, + onProviderChange, + onClose, + onExecute, + isExecuting, +}: AiSidebarProps) { + const [prompt, setPrompt] = useState(""); + const [runs, setRuns] = useState([]); + const [models, setModels] = useState>>({}); + const [installed, setInstalled] = useState(null); + const [customModelOpen, setCustomModelOpen] = useState(false); + const inputRef = useRef(null); + const transcriptRef = useRef(null); + const Icon = providerIcons[provider]; + + useEffect(() => { + if (open && !isExecuting) inputRef.current?.focus(); + }, [open, isExecuting]); + + // Which harnesses are actually on this machine. Uninstalled ones stay in the + // picker but are disabled, so the list explains itself rather than hiding. + useEffect(() => { + if (!open) return; + let active = true; + getAvailableAiProviders() + .then((providers) => active && setInstalled(providers)) + .catch(() => active && setInstalled([])); + return () => { + active = false; + }; + }, [open]); + + useEffect(() => { + if (!open) return; + let active = true; + invoke("get_settings") + .then((settings) => { + if (!active) return; + const stored: Partial> = { + ...(settings.aiModels as Partial>), + }; + // Pre-dates per-harness models: carry the standalone Ollama model over. + if (settings.ollamaModel && !stored.ollama) stored.ollama = settings.ollamaModel; + setModels(stored); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [open]); + + // Close the custom-model field when switching harness; it belongs to one list. + useEffect(() => setCustomModelOpen(false), [provider]); + + useEffect(() => { + transcriptRef.current?.scrollTo({ + top: transcriptRef.current.scrollHeight, + behavior: "smooth", + }); + }, [runs]); + + const model = models[provider] ?? defaultModelFor(provider); + + const modelOptions = useMemo(() => { + const options = [...AI_PROVIDER_MODELS[provider]]; + // A custom or previously saved model keeps its own entry so it stays visible. + if (model && !options.some((option) => option.id === model)) { + options.push({ id: model, label: model }); + } + return options; + }, [provider, model]); + + const selectModel = useCallback( + (next: string) => { + const updated = { ...models, [provider]: next }; + setModels(updated); + invoke("get_settings") + .then((settings) => + invoke("update_settings", { + newSettings: { ...settings, aiModels: updated }, + }), + ) + .catch(() => {}); + }, + [models, provider], + ); + + if (!open) return null; + + const appendStep = (runId: string, event: AiStreamEvent) => { + if (event.kind === "done") return; + setRuns((current) => + current.map((run) => + run.id === runId + ? { + ...run, + steps: [ + ...run.steps, + event.kind === "tool" + ? { kind: "tool" as const, name: event.name, detail: event.detail } + : { kind: "text" as const, text: event.text }, + ], + } + : run, + ), + ); + }; + + const submit = async () => { + const value = prompt.trim(); + if (!value || isExecuting) return; + + const runId = crypto.randomUUID(); + const runProvider = provider; + const runModel = model; + // Effort isn't exposed in the UI; medium suits note-sized work. + const runEffort = supportsEffort(runProvider) ? DEFAULT_AI_EFFORT : null; + setPrompt(""); + setRuns((current) => [ + ...current, + { + id: runId, + prompt: value, + provider: runProvider, + model: runModel, + status: "running", + steps: [], + output: "", + error: null, + }, + ]); + + const unlisten = supportsStreaming(runProvider) + ? await onAiStreamLine(runId, (line) => { + for (const event of parseStreamLine(runProvider, line)) { + appendStep(runId, event); + } + }) + : undefined; + + try { + const result = await onExecute( + value, + runId, + runModel || undefined, + runEffort ?? undefined, + ); + setRuns((current) => + current.map((run) => + run.id === runId + ? { + ...run, + status: result?.success ? "done" : "error", + output: result?.output ?? "", + error: result?.error ?? "Run failed", + } + : run, + ), + ); + } finally { + unlisten?.(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void submit(); + } + }; + + return ( +