diff --git a/apps/staged/package.json b/apps/staged/package.json index ff4d9c63f..9de3cb98e 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -53,6 +53,7 @@ "@tauri-apps/plugin-updater": "^2.10.0", "ansi-to-html": "^0.7.2", "marked": "^17.0.1", + "pikchr-js": "0.1.4", "sanitize-html": "^2.17.0", "shiki": "^3.20.0" }, diff --git a/apps/staged/src-tauri/resources/pikchr/grammar.md b/apps/staged/src-tauri/resources/pikchr/grammar.md new file mode 100644 index 000000000..09274984c --- /dev/null +++ b/apps/staged/src-tauri/resources/pikchr/grammar.md @@ -0,0 +1,393 @@ + + +# Pikchr Grammar + +This file describes the grammar of the input files to Pikchr. Keywords +and operators are shown in **bold**. Non-terminal symbols are shown +in *italic*. Special token classes are shown in ALL-CAPS. A grammar +symbol followed by "*" means zero-or-more. A grammar symbol +followed by "?" means zero-or-one. Parentheses are used for grouping. +Two grammar symbols within "(..|..)" means one or the other. + Marks of the form +"[▶info](./grammar.md)" are links to more information and are +not part of the grammar. + +The following special token classes are recognized: + + * NEWLINE → A un-escaped newline character, U+000A. + A backslash followed by zero or more whitespace characters + and then a U+000A character is interpreted as ordinary whitespace, + not as a NEWLINE. + + * LABEL → An object or place label starting with an + upper-case ASCII letter and continuing with zero or more + ASCII letters, digits, and/or underscores. A LABEL always starts + with an upper-case letter. + + * VARIABLE → A variable name consisting of a lower-case + ASCII letter or "$" or "@" and followed by zero or more + ASCII letters, digits, and/or underscores. VARIABLEs may + contain upper-case letters, but they never begin with an upper-case. + In this way, VARIABLEs are distinct from LABELs. + + * NUMBER → A numeric literal. The value can be a decimal + integer, a floating point value, or a hexadecimal literal + starting with "0x". Decimal and floating point values can + optionally be followed by a two-character unit designator that is + one of: "in", "cm", "px", "pt", "pc", or "mm". There can be + no whitespace in between the numeric portion of the constant and + the unit. + + * ORDINAL → A non-zero integer literal followed by one of the + suffixes "st", "nd", "rd", or "th". Examples: "1st", "2nd", + "3rd", "4th", "5th", and so forth. As a special case, "first" + is accepted as an alternative spelling of "1st". + + * STRING → A string literal that begins and ends with + double-quotes (U+0022). Within the string literal, a double-quote + character can be escaped using backslash (U+005c). A backslash + can also be used to escape a backslash. No other escape sequences + are recognized and standalone backslashes are elided from the output. + Newlines are permitted in strings. + + * COLORNAME → One of the 140 official HTML color names, in + any mixture of upper and lower cases. The value of a COLORNAME is + an integer which is the 24-bit RGB value of that color. Two + additional color names of "None" and "Off" are also recognized and + have a value of -1. + + * CODEBLOCK → All tokens contained within nested {...}. This + is only used as the body of a "define" statement. + +There are many non-terminals in the grammar, but a few are more important. +If you are new to the Pikchr language, begin by focusing on these +six: + + * *statement* → A Pikchr script is just a list of statements. + + * *attribute* → Each graphic object is configured with zero or + more attributes. + + * *object* → A reference to a prior graphic object, or `this` to + refer to the current object. + + * *place* → A specific point associated with an *object*. + + * *position* → Any (2-D) point in space. An (x,y) pair. + + * *expr* → A scalar expression. + + +A complete input file to Pikchr consists of a single *statement-list*. + +## *statement-list*: [▶info](./stmtlist.md) + + * *statement*? + * *statement-list* NEWLINE *statement*? + * *statement-list* **;** *statement*? + +## *statement*: [▶info](./stmt.md) + * *object-definition* + * LABEL **:** *object-definition* + * LABEL **:** *place* + * *direction* + * VARIABLE *assignment-op* *expr* + * **define** VARIABLE CODEBLOCK [▶info](./macro.md) + * **print** *print-argument* (**,** *print-argument*)\* + * **assert (** *expr* **==** *expr* **)** + * **assert (** *position* **==** *position* **)** + + +## *direction*: + * **right** + * **down** + * **left** + * **up** + +## *assignment-op*: + * **=** + * **+=** + * **-=** + * **\*=** + * **/=** + +## *print-argument*: + * *expr* + * STRING + +## *object-definition*: + * *object-class* *attribute*\* + * STRING *text-attribute*\* *attribute*\* + * **[** *statement-list* **]** *attribute*\* + +## *object-class*: + * **arc** + * **arrow** + * **box** [▶info](./boxobj.md) + * **circle** [▶info](./circleobj.md) + * **cylinder** [▶info](./cylinderobj.md) + * **diamond** [▶info](./diamondobj.md) + * **dot** + * **ellipse** [▶info](./ellipseobj.md) + * **file** [▶info](./fileobj.md) + * **line** + * **move** + * **oval** [▶info](./ovalobj.md) + * **spline** + * **text** + +## *attribute*: + * *path-attribute* [▶info](./pathattr.md) + * *location-attribute* [▶info](./locattr.md) + * STRING *text-attribute*\* [▶info](./annotate.md) + * **same** + * **same as** *object* + * *numeric-property* *new-property-value* + * **dashed** *expr*? + * **dotted** *expr*? + * **color** *color-expr* + * **fill** *color-expr* + * **behind** *object* [▶info](./behind.md) + * **cw** + * **ccw** + * **<-** [▶info](./arrowdir.md) + * **->** [▶info](./arrowdir.md) + * **<->** [▶info](./arrowdir.md) + * **invis**|**invisible** [▶info](./invis.md) + * **thick** [▶info](./thickthin.md) + * **thin** [▶info](./thickthin.md) + * **solid** [▶info](./thickthin.md) + * **chop** [▶info](./chop.md) + * **fit** [▶info](./fit.md) + +## *color-expr*: [▶info](./colorexpr.md) + * *expr* + +## *new-property-value*: [▶info](./newpropval.md) + * *expr* + * *expr* **%** + +## *numeric-property*: [▶info](./numprop.md) + * **diameter** + * **ht** + * **height** + * **rad** + * **radius** + * **thickness** + * **width** + * **wid** + +## *text-attribute*: [▶info](./textattr.md) + * **above** + * **aligned** + * **below** + * **big** + * **bold** + * **mono** + * **monospace** + * **center** + * **italic** + * **ljust** + * **rjust** + * **small** + +## *path-attribute*: [▶info](./pathattr.md) + * **from** *position* + * **then**? **to** *position* + * **then**? **go**? *direction* *line-length*? + * **then**? **go**? *direction* **until**? **even with** *position* + * (**then**|**go**) *line-length*? **heading** *compass-angle* + * (**then**|**go**) *line-length*? *compass-direction* + * **close** + +## *line-length*: [▶info](./linelen.md) + + * *expr* + * *expr* **%** + +## *compass-angle*: [▶info](./compassangle.md) + + * *expr* + +## *compass-direction*: + * **n** + * **north** + * **ne** + * **e** + * **east** + * **se** + * **s** + * **south** + * **sw** + * **w** + * **west** + * **nw** + +## *location-attribute*: [▶info](./locattr.md) + * **at** *position* + * **with** *edgename* **at** *position* + * **with** *dot-edgename* **at** *position* + +## *position*: [▶info](./position.md) + + * *expr* **,** *expr* + * *place* + * *place* **+** *expr* **,** *expr* + * *place* **-** *expr* **,** *expr* + * *place* **+ (** *expr* **,** *expr* **)** + * *place* **- (** *expr* **,** *expr* **)** + * **(** *position* **,** *position* **)** + * **(** *position* **)** + * *fraction* **of the way between** *position* **and** *position* + * *fraction* **way between** *position* **and** *position* + * *fraction* **between** *position* **and** *position* + * *fraction* **<** *position* **,** *position* **>** + * *distance* *which-way-from* *position* + +## *fraction*: + * *expr* + +## *distance* + * *expr* + +## *which-way-from*: + + * **above** + * **below** + * **right of** + * **left of** + * **n of** + * **north of** + * **ne of** + * **e of** + * **east of** + * **se of** + * **s of** + * **south of** + * **sw of** + * **w of** + * **west of** + * **nw of** + * **heading** *compass-angle* **from** + +## *place*: [▶info](./place.md) + + * *object* + * *object* *dot-edgename* + * *edgename* **of** *object* + * ORDINAL **vertex of** *object* + +## *object*: + + * LABEL + * *object* **.** LABEL + * *nth-object* **of**|**in** *object* + +## *nth-object*: + + * ORDINAL *object-class* + * ORDINAL **last** *object-class* + * ORDINAL **previous** *object-class* + * **last** *object-class* + * **previous** *object-class* + * **last** + * **previous** + * ORDINAL **[]** + * ORDINAL **last []** + * ORDINAL **previous []** + * **last []** + * **previous []** + +## *dot-edgename*: + * **.n** + * **.north** + * **.t** + * **.top** + * **.ne** + * **.e** + * **.east** + * **.right** + * **.se** + * **.s** + * **.south** + * **.bot** + * **.bottom** + * **.sw** + * **.w** + * **.west** + * **.left** + * **.nw** + * **.c** + * **.center** + * **.start** + * **.end** + +## *edgename*: + * **n** + * **north** + * **ne** + * **e** + * **east** + * **se** + * **s** + * **south** + * **sw** + * **w** + * **west** + * **nw** + * **t** + * **top** + * **bot** + * **bottom** + * **left** + * **right** + * **c** + * **center** + * **start** + * **end** + + +## *expr*: + + * NUMBER + * VARIABLE + * COLORNAME + * *place* **.x** + * *place* **.y** + * *object* *dot-property* + * **(** *expr* **)** + * *expr* **+** *expr* + * *expr* **-** *expr* + * *expr* **\*** *expr* + * *expr* **/** *expr* + * **-** *expr* + * **+** *expr* + * **abs (** *expr* **)** + * **cos (** *expr* **)** + * **dist (** *position* **,** *position* **)** + * **int (** *expr* **)** + * **max (** *expr* **,** *expr* **)** + * **min (** *expr* **,** *expr* **)** + * **sin (** *expr* **)** + * **sqrt (** *expr* **)** + +## *dot-property*: + + * **.color** + * **.dashed** + * **.diameter** + * **.dotted** + * **.fill** + * **.ht** + * **.height** + * **.rad** + * **.radius** + * **.thickness** + * **.wid** + * **.width** diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 7eef6aed6..669fd479d 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2277,6 +2277,7 @@ pub fn run() { session_commands::count_assistant_messages_after, session_commands::start_session, session_commands::resume_session, + session_commands::build_note_followup_message, session_commands::cancel_session, session_commands::delete_session, session_commands::start_branch_session, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 363e60ea5..98b0a660b 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -21,6 +21,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; +use tauri::path::BaseDirectory; +use tauri::Manager; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{self, AcpProviderInfo}; @@ -29,6 +31,17 @@ use crate::git; use crate::session_runner::{self, SessionConfig}; use crate::store::{self, Store}; +const PIKCHR_GRAMMAR_RESOURCE: &str = "resources/pikchr/grammar.md"; +const PIKCHR_GRAMMAR_REMOTE_PATH_PREFIX: &str = "/tmp/staged-pikchr-grammar-"; +const PIKCHR_GRAMMAR_REMOTE_PATH_SUFFIX: &str = ".md"; +pub(crate) const PIKCHR_GRAMMAR_URL: &str = "https://pikchr.org/home/doc/trunk/doc/grammar.md"; + +enum RemotePikchrGrammarStaging { + NotNeeded, + Upload { bytes: Vec, remote_path: String }, + FallbackUrl, +} + // ============================================================================= // Helper — duplicated from lib.rs to avoid circular deps. If this grows, // consider extracting a shared `state.rs`. @@ -66,6 +79,193 @@ where .map_err(|e| e.to_string()) } +fn bundled_pikchr_grammar_path(app_handle: &tauri::AppHandle) -> Option { + if let Ok(path) = app_handle + .path() + .resolve(PIKCHR_GRAMMAR_RESOURCE, BaseDirectory::Resource) + { + if path.is_file() { + return Some(path); + } + } + + let source_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(PIKCHR_GRAMMAR_RESOURCE); + if source_path.is_file() { + return Some(source_path); + } + + None +} + +fn bundled_pikchr_grammar_bytes(app_handle: &tauri::AppHandle) -> Option> { + let Some(grammar_path) = bundled_pikchr_grammar_path(app_handle) else { + log::warn!("Bundled Pikchr grammar resource not found; falling back to public URL"); + return None; + }; + + match std::fs::read(&grammar_path) { + Ok(bytes) => Some(bytes), + Err(e) => { + log::warn!( + "Failed to read bundled Pikchr grammar at {}: {e}", + grammar_path.display() + ); + None + } + } +} + +fn generated_pikchr_grammar_remote_path() -> String { + format!( + "{PIKCHR_GRAMMAR_REMOTE_PATH_PREFIX}{}{PIKCHR_GRAMMAR_REMOTE_PATH_SUFFIX}", + uuid::Uuid::new_v4() + ) +} + +fn remote_pikchr_grammar_staging( + app_handle: &tauri::AppHandle, + session_type: &BranchSessionType, +) -> RemotePikchrGrammarStaging { + if !matches!(session_type, BranchSessionType::Note) { + return RemotePikchrGrammarStaging::NotNeeded; + } + + match bundled_pikchr_grammar_bytes(app_handle) { + Some(bytes) => RemotePikchrGrammarStaging::Upload { + bytes, + remote_path: generated_pikchr_grammar_remote_path(), + }, + None => RemotePikchrGrammarStaging::FallbackUrl, + } +} + +fn local_pikchr_grammar_reference_for_session( + app_handle: &tauri::AppHandle, + session_type: &BranchSessionType, +) -> String { + if matches!(session_type, BranchSessionType::Note) { + resolve_pikchr_grammar_reference(app_handle, None) + } else { + PIKCHR_GRAMMAR_URL.to_string() + } +} + +fn upload_pikchr_grammar_to_remote_with_writer( + workspace_name: &str, + bytes: &[u8], + remote_path: String, + write: F, +) -> String +where + F: FnOnce(&str, &[u8], &str) -> Result<(), String>, +{ + match write(workspace_name, bytes, &remote_path) { + Ok(()) => remote_path, + Err(e) => { + log::warn!("Failed to copy Pikchr grammar to remote workspace {workspace_name}: {e}"); + PIKCHR_GRAMMAR_URL.to_string() + } + } +} + +fn upload_pikchr_grammar_to_remote( + workspace_name: &str, + bytes: &[u8], + remote_path: String, +) -> String { + upload_pikchr_grammar_to_remote_with_writer( + workspace_name, + bytes, + remote_path, + write_bytes_to_remote, + ) +} + +pub(crate) fn resolve_pikchr_grammar_reference( + app_handle: &tauri::AppHandle, + workspace_name: Option<&str>, +) -> String { + let Some(grammar_path) = bundled_pikchr_grammar_path(app_handle) else { + log::warn!("Bundled Pikchr grammar resource not found; falling back to public URL"); + return PIKCHR_GRAMMAR_URL.to_string(); + }; + + if let Some(workspace_name) = workspace_name { + return match std::fs::read(&grammar_path) { + Ok(bytes) => upload_pikchr_grammar_to_remote( + workspace_name, + &bytes, + generated_pikchr_grammar_remote_path(), + ), + Err(e) => { + log::warn!( + "Failed to read bundled Pikchr grammar at {}: {e}", + grammar_path.display() + ); + PIKCHR_GRAMMAR_URL.to_string() + } + }; + } + + grammar_path.to_string_lossy().into_owned() +} + +fn pikchr_note_guidance(reference: &str) -> String { + format!( + "Staged notes support rendered diagrams in fenced `pikchr` code blocks. \ +If you need the Pikchr grammar while writing a diagram, read the reference at: {reference}" + ) +} + +pub(crate) fn build_note_followup_message_with_pikchr_reference( + has_parsed_note: bool, + pikchr_grammar_reference: &str, +) -> String { + let visible_request = if has_parsed_note { + "Please update the note to reflect the latest chat." + } else { + "Please write the note for this session." + }; + let linked_note_action = if has_parsed_note { + "update the linked note" + } else { + "write the linked note" + }; + let pikchr_guidance = pikchr_note_guidance(pikchr_grammar_reference); + + format!( + "\n\ +The user is asking you to {linked_note_action} from the latest chat history.\n\ +\n\ +Use the existing conversation context. Do not create commits.\n\ +\n\ +{pikchr_guidance}\n\ +\n\ +Your final response must include a suggested-next-steps fenced block followed by the note content after a horizontal rule:\n\ +\n\ +```suggested-next-steps\n\ +{{\"suggestedNextCommitStep\": null, \"suggestedNextNoteStep\": null}}\n\ +```\n\ +\n\ +---\n\ +# \n\ +<Body>\n\ +\n\ +Formatting requirements:\n\ +- The opening fence line for suggested-next-steps must be exactly: ```suggested-next-steps\n\ +- The closing fence line must be exactly: ```\n\ +- Put only a JSON object inside the suggested-next-steps block.\n\ +- Include both nullable string fields: suggestedNextCommitStep and suggestedNextNoteStep.\n\ +- Keep suggested next steps concise; use null when there is no clear next action.\n\ +- The `---` separator must be on its own line.\n\ +- The note content must start immediately after `---` with a markdown H1.\n\ +- Do not wrap the note in code fences.\n\ +</action>\n\ +\n\ +{visible_request}" + ) +} + // ============================================================================= // Provider discovery // ============================================================================= @@ -406,6 +606,58 @@ pub(crate) fn infer_branch_resume_session_type(prompt: &str) -> Option<&'static } } +#[tauri::command] +pub async fn build_note_followup_message( + store: tauri::State<'_, Mutex<Option<Arc<Store>>>>, + app_handle: tauri::AppHandle, + session_id: String, + branch_id: Option<String>, + has_parsed_note: bool, +) -> Result<String, String> { + let store = get_store(&store)?; + + tauri::async_runtime::spawn_blocking(move || { + store + .get_session(&session_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Session not found: {session_id}"))?; + + let linked_commit = store.get_commit_by_session(&session_id).ok().flatten(); + let linked_note = store.get_note_by_session(&session_id).ok().flatten(); + let linked_review = store.get_review_by_session(&session_id).ok().flatten(); + + let branch_from_id = branch_id + .as_deref() + .and_then(|bid| store.get_branch(bid).ok().flatten()); + + let linked_branch = if branch_from_id.is_some() { + branch_from_id + } else if let Some(commit) = &linked_commit { + store.get_branch(&commit.branch_id).ok().flatten() + } else if let Some(note) = &linked_note { + store.get_branch(¬e.branch_id).ok().flatten() + } else if let Some(review) = &linked_review { + store.get_branch(&review.branch_id).ok().flatten() + } else { + None + }; + + let pikchr_grammar_reference = resolve_pikchr_grammar_reference( + &app_handle, + linked_branch + .as_ref() + .and_then(|branch| branch.workspace_name.as_deref()), + ); + + Ok(build_note_followup_message_with_pikchr_reference( + has_parsed_note, + &pikchr_grammar_reference, + )) + }) + .await + .map_err(|e| format!("Failed to build note follow-up message: {e}"))? +} + #[tauri::command] pub fn cancel_session( registry: tauri::State<'_, Arc<session_runner::SessionRegistry>>, @@ -786,7 +1038,10 @@ for example #note:123, #commit:<sha>, and #review:456. When starting a repo-leve note, do not paste or rewrite the note contents; reference the note and relevant section instead, \ for example: `Implement \"Step 5: unit tests\" from #note:123`."; -pub(crate) fn build_project_session_action_instructions(is_remote: bool) -> String { +pub(crate) fn build_project_session_action_instructions_with_pikchr_reference( + is_remote: bool, + pikchr_grammar_reference: &str, +) -> String { let preamble = if is_remote { "This top-level project session runs locally and acts as a coordinator. \ For repository-specific execution, use MCP subagent tools.\n\n\ @@ -833,6 +1088,8 @@ repository edits directly here; use `start_repo_session` for implementation work "" }; + let pikchr_guidance = pikchr_note_guidance(pikchr_grammar_reference); + format!( "The user is requesting work at the project level. Investigate and \ fulfill the request below, then produce a project note summarizing what you found and any \ @@ -840,6 +1097,7 @@ actions taken.\n\n\ {preamble}\n\n\ {start_repo_session_desc}\n\n\ {PROJECT_SESSION_TIMELINE_REFERENCE_GUIDANCE}\n\n\ +{pikchr_guidance}\n\n\ - add_project_repo: Use this when the task requires a repository that isn't yet in the \ project. Pass the GitHub repo slug to add it.\n\n\ IMPORTANT: `add_project_repo` and `start_repo_session` are MCP tools, not shell commands. \ @@ -885,7 +1143,11 @@ pub async fn start_project_session( let project_context = build_project_session_context(&store, &project, None); let is_remote = project.location == store::ProjectLocation::Remote; - let action_instructions = build_project_session_action_instructions(is_remote); + let pikchr_grammar_reference = resolve_pikchr_grammar_reference(&app_handle, None); + let action_instructions = build_project_session_action_instructions_with_pikchr_reference( + is_remote, + &pikchr_grammar_reference, + ); let full_prompt = format!( "<action>\n{action_instructions}\n\nProject information:\n{project_context}\n</action>\n\n{prompt}" @@ -984,6 +1246,7 @@ fn resolve_branch_session_provider( #[allow(clippy::too_many_arguments)] async fn prepare_branch_session_start( store: &Arc<Store>, + app_handle: &tauri::AppHandle, branch_id: &str, prompt: &str, session_type: BranchSessionType, @@ -1004,29 +1267,35 @@ async fn prepare_branch_session_start( // Resolve working directory and branch context. // Remote branches use ws_exec for git operations; local branches use the worktree directly. - let (working_dir, branch_context) = if is_remote { + let (working_dir, branch_context, pikchr_grammar_reference) = if is_remote { // For remote branches, use the derived clone path as a fallback working dir. // The actual work happens via ws_exec, not local filesystem. let fallback_dir = resolve_branch_repo_slug(store, &project, &branch) .and_then(|repo| crate::paths::repos_dir().map(|d| d.join(repo))) .unwrap_or_else(|| PathBuf::from("/tmp")); let workspace_name = branch.workspace_name.as_deref().unwrap().to_string(); + let pikchr_grammar_staging = remote_pikchr_grammar_staging(app_handle, &session_type); let base_branch = branch.base_branch.clone(); let store_for_context = Arc::clone(store); let branch_id_for_context = branch_id.to_string(); let project_id_for_context = branch.project_id.clone(); - let ctx = tauri::async_runtime::spawn_blocking(move || { + let remote_context = tauri::async_runtime::spawn_blocking(move || { build_remote_branch_context( &workspace_name, &base_branch, &store_for_context, &branch_id_for_context, &project_id_for_context, + pikchr_grammar_staging, ) }) .await .map_err(|e| format!("Failed to build remote branch context: {e}"))?; - (fallback_dir, ctx) + ( + fallback_dir, + remote_context.branch_context, + remote_context.pikchr_grammar_reference, + ) } else { let workdir = store .get_workdir_for_branch(branch_id) @@ -1057,7 +1326,9 @@ async fn prepare_branch_session_start( branch_id, &branch.project_id, ); - (worktree_path, ctx) + let pikchr_grammar_reference = + local_pikchr_grammar_reference_for_session(app_handle, &session_type); + (worktree_path, ctx, pikchr_grammar_reference) }; let pre_head_sha = if matches!(session_type, BranchSessionType::Commit) { @@ -1101,13 +1372,14 @@ async fn prepare_branch_session_start( // Build the full prompt with action instructions + project information + branch context. let project_information = build_project_context(store, &project, &branch); - let full_prompt = build_full_prompt( + let full_prompt = build_full_prompt_with_pikchr_reference( prompt, &project_information, &branch_context, &session_type, launch_context, Some(&branch.base_branch), + &pikchr_grammar_reference, ); // Resolve the actual workspace path for remote branches so the remote agent @@ -1329,6 +1601,7 @@ pub async fn start_or_queue_branch_session_for_store( let prepared = prepare_branch_session_start( &store, + &app_handle, &branch_id, &prompt, session_type.clone(), @@ -1614,27 +1887,33 @@ async fn start_queued_session_for_branch( }; // Resolve working directory and branch context. - let (working_dir, branch_context) = if is_remote { + let (working_dir, branch_context, pikchr_grammar_reference) = if is_remote { let fallback_dir = resolve_branch_repo_slug(&store, &project, &branch) .and_then(|repo| crate::paths::repos_dir().map(|d| d.join(repo))) .unwrap_or_else(|| PathBuf::from("/tmp")); let workspace_name = branch.workspace_name.as_deref().unwrap().to_string(); + let pikchr_grammar_staging = remote_pikchr_grammar_staging(&app_handle, &session_type); let base_branch = branch.base_branch.clone(); let store_for_context = Arc::clone(&store); let branch_id_for_context = branch_id.clone(); let project_id_for_context = branch.project_id.clone(); - let ctx = tauri::async_runtime::spawn_blocking(move || { + let remote_context = tauri::async_runtime::spawn_blocking(move || { build_remote_branch_context( &workspace_name, &base_branch, &store_for_context, &branch_id_for_context, &project_id_for_context, + pikchr_grammar_staging, ) }) .await .map_err(|e| format!("Failed to build remote branch context: {e}"))?; - (fallback_dir, ctx) + ( + fallback_dir, + remote_context.branch_context, + remote_context.pikchr_grammar_reference, + ) } else { let workdir = store .get_workdir_for_branch(&branch_id) @@ -1662,18 +1941,21 @@ async fn start_queued_session_for_branch( &branch_id, &branch.project_id, ); - (worktree_path, ctx) + let pikchr_grammar_reference = + local_pikchr_grammar_reference_for_session(&app_handle, &session_type); + (worktree_path, ctx, pikchr_grammar_reference) }; // Build the full prompt with context. let project_information = build_project_context(&store, &project, &branch); - let full_prompt = build_full_prompt( + let full_prompt = build_full_prompt_with_pikchr_reference( &prompt, &project_information, &branch_context, &session_type, launch_context.as_ref(), Some(&branch.base_branch), + &pikchr_grammar_reference, ); // Atomically transition session from queued to running. @@ -1959,18 +2241,19 @@ pub async fn trigger_auto_review( let store_for_context = Arc::clone(&store); let branch_id_for_context = branch_id.clone(); let project_id_for_context = branch.project_id.clone(); - let ctx = tauri::async_runtime::spawn_blocking(move || { + let remote_context = tauri::async_runtime::spawn_blocking(move || { build_remote_branch_context( &workspace_name, &base_branch, &store_for_context, &branch_id_for_context, &project_id_for_context, + RemotePikchrGrammarStaging::NotNeeded, ) }) .await .map_err(|e| format!("Failed to build remote branch context: {e}"))?; - (fallback_dir, ctx) + (fallback_dir, remote_context.branch_context) } else { let workdir = store .get_workdir_for_branch(&branch_id) @@ -2266,13 +2549,19 @@ pub(crate) fn build_branch_context( /// /// Uses `blox ws_exec` to run git commands inside the remote workspace, /// and reads notes from the DB (which works regardless of worktree location). -pub(crate) fn build_remote_branch_context( +struct RemoteBranchContext { + branch_context: String, + pikchr_grammar_reference: String, +} + +fn build_remote_branch_context( workspace_name: &str, base_branch: &str, store: &Arc<Store>, branch_id: &str, project_id: &str, -) -> String { + pikchr_grammar_staging: RemotePikchrGrammarStaging, +) -> RemoteBranchContext { let mut parts = vec![context_preamble()]; let mut timeline: Vec<TimelineEntry> = Vec::new(); let mut visible_shas: HashSet<String> = HashSet::new(); @@ -2310,9 +2599,11 @@ pub(crate) fn build_remote_branch_context( } } - // Notes, reviews, images, and project notes written to temp files inside - // the remote workspace via ws_exec — run in parallel to reduce round trips. + // Notes, reviews, images, project notes, and optional Pikchr grammar are + // written to remote temp files in parallel to reduce ws_exec round trips. let max_commit_ts = timeline.iter().map(|e| e.timestamp).max(); + let mut pikchr_grammar_reference = PIKCHR_GRAMMAR_URL.to_string(); + std::thread::scope(|s| { let note_handle = s.spawn(|| note_timeline_entries(store, branch_id, Some(workspace_name))); let visible_shas = &visible_shas; @@ -2329,6 +2620,15 @@ pub(crate) fn build_remote_branch_context( s.spawn(|| image_timeline_entries(store, branch_id, Some(workspace_name), project_id)); let project_note_handle = s.spawn(|| project_note_timeline_entries(store, project_id, Some(workspace_name))); + let pikchr_grammar_handle = match &pikchr_grammar_staging { + RemotePikchrGrammarStaging::Upload { bytes, remote_path } => { + let remote_path = remote_path.clone(); + Some(s.spawn(move || { + upload_pikchr_grammar_to_remote(workspace_name, bytes.as_slice(), remote_path) + })) + } + RemotePikchrGrammarStaging::NotNeeded | RemotePikchrGrammarStaging::FallbackUrl => None, + }; match note_handle.join() { Ok(entries) => timeline.extend(entries), @@ -2346,10 +2646,19 @@ pub(crate) fn build_remote_branch_context( Ok(entries) => timeline.extend(entries), Err(_) => log::error!("project_note_timeline_entries thread panicked"), } + if let Some(handle) = pikchr_grammar_handle { + match handle.join() { + Ok(reference) => pikchr_grammar_reference = reference, + Err(_) => log::error!("Pikchr grammar upload thread panicked"), + } + } }); parts.push(render_timeline(timeline, None)); - parts.join("\n\n") + RemoteBranchContext { + branch_context: parts.join("\n\n"), + pikchr_grammar_reference, + } } /// Shared preamble for branch context blocks. @@ -3265,7 +3574,27 @@ pub(crate) fn build_full_prompt( launch_context: Option<&BranchSessionLaunchContext>, base_branch: Option<&str>, ) -> String { - let action_instructions = match session_type { + build_full_prompt_with_pikchr_reference( + user_prompt, + project_information, + branch_context, + session_type, + launch_context, + base_branch, + PIKCHR_GRAMMAR_URL, + ) +} + +pub(crate) fn build_full_prompt_with_pikchr_reference( + user_prompt: &str, + project_information: &str, + branch_context: &str, + session_type: &BranchSessionType, + launch_context: Option<&BranchSessionLaunchContext>, + base_branch: Option<&str>, + pikchr_grammar_reference: &str, +) -> String { + let mut action_instructions = match session_type { BranchSessionType::Note => { "The user is requesting a note. Generate a note based on their prompt below. @@ -3411,6 +3740,11 @@ Rules:\n\ } }; + if matches!(session_type, BranchSessionType::Note) { + action_instructions.push_str("\n\n"); + action_instructions.push_str(&pikchr_note_guidance(pikchr_grammar_reference)); + } + let action_tag = format!( "<action>\n{action_instructions}\n\nProject information:\n{project_information}\n</action>" ); @@ -4113,20 +4447,134 @@ mod tests { assert!(!prompt.contains("repo session is taking a long time")); } + fn assert_pikchr_note_guidance(prompt: &str, reference: &str) { + assert!(prompt.contains("Staged notes support rendered diagrams")); + assert!(prompt.contains("fenced `pikchr` code blocks")); + assert!(prompt.contains("Pikchr grammar")); + assert!(prompt.contains(reference)); + } + + #[test] + fn generated_remote_pikchr_grammar_paths_are_unique_temp_markdown_files() { + let first = generated_pikchr_grammar_remote_path(); + let second = generated_pikchr_grammar_remote_path(); + + assert_ne!(first, second); + for path in [first, second] { + assert!(path.starts_with(PIKCHR_GRAMMAR_REMOTE_PATH_PREFIX)); + assert!(path.ends_with(PIKCHR_GRAMMAR_REMOTE_PATH_SUFFIX)); + + let uuid_part = path + .strip_prefix(PIKCHR_GRAMMAR_REMOTE_PATH_PREFIX) + .and_then(|path| path.strip_suffix(PIKCHR_GRAMMAR_REMOTE_PATH_SUFFIX)) + .expect("generated path should contain a UUID between prefix and suffix"); + uuid::Uuid::parse_str(uuid_part).expect("generated path should include a UUID"); + } + } + + #[test] + fn successful_remote_pikchr_grammar_upload_returns_generated_path() { + let expected_path = generated_pikchr_grammar_remote_path(); + let returned_path = upload_pikchr_grammar_to_remote_with_writer( + "test-workspace", + b"grammar bytes", + expected_path.clone(), + |workspace_name, bytes, remote_path| { + assert_eq!(workspace_name, "test-workspace"); + assert_eq!(bytes, b"grammar bytes"); + assert_eq!(remote_path, expected_path); + Ok(()) + }, + ); + + assert_eq!(returned_path, expected_path); + } + + #[test] + fn failed_remote_pikchr_grammar_upload_falls_back_to_public_url() { + let generated_path = generated_pikchr_grammar_remote_path(); + let returned_path = upload_pikchr_grammar_to_remote_with_writer( + "test-workspace", + b"grammar bytes", + generated_path, + |_workspace_name, _bytes, _remote_path| Err("remote unavailable".to_string()), + ); + + assert_eq!(returned_path, PIKCHR_GRAMMAR_URL); + } + #[test] fn local_project_session_prompt_includes_timeline_reference_guidance() { - let prompt = build_project_session_action_instructions(false); + let prompt = build_project_session_action_instructions_with_pikchr_reference( + false, + PIKCHR_GRAMMAR_URL, + ); assert_project_session_reference_guidance(&prompt); assert_project_session_repo_session_progress_guidance(&prompt); + assert_pikchr_note_guidance(&prompt, PIKCHR_GRAMMAR_URL); } #[test] fn remote_project_session_prompt_includes_timeline_reference_guidance() { - let prompt = build_project_session_action_instructions(true); + let prompt = build_project_session_action_instructions_with_pikchr_reference( + true, + PIKCHR_GRAMMAR_URL, + ); assert_project_session_reference_guidance(&prompt); assert_project_session_repo_session_progress_guidance(&prompt); + assert_pikchr_note_guidance(&prompt, PIKCHR_GRAMMAR_URL); + } + + #[test] + fn project_session_prompt_uses_supplied_pikchr_reference() { + let prompt = build_project_session_action_instructions_with_pikchr_reference( + false, + "/tmp/staged/pikchr/grammar.md", + ); + + assert_pikchr_note_guidance(&prompt, "/tmp/staged/pikchr/grammar.md"); + } + + #[test] + fn note_prompt_uses_supplied_pikchr_reference() { + let prompt = build_full_prompt_with_pikchr_reference( + "user prompt", + "project info", + "branch context", + &BranchSessionType::Note, + None, + None, + "/tmp/staged/pikchr/grammar.md", + ); + + assert_pikchr_note_guidance(&prompt, "/tmp/staged/pikchr/grammar.md"); + } + + #[test] + fn note_followup_prompt_uses_supplied_local_pikchr_reference() { + let prompt = build_note_followup_message_with_pikchr_reference( + true, + "/Applications/Staged.app/Contents/Resources/resources/pikchr/grammar.md", + ); + + assert!(prompt.contains("The user is asking you to update the linked note")); + assert!(prompt.contains("Please update the note to reflect the latest chat.")); + assert_pikchr_note_guidance( + &prompt, + "/Applications/Staged.app/Contents/Resources/resources/pikchr/grammar.md", + ); + } + + #[test] + fn note_followup_prompt_uses_supplied_remote_pikchr_reference() { + let remote_path = generated_pikchr_grammar_remote_path(); + let prompt = build_note_followup_message_with_pikchr_reference(false, &remote_path); + + assert!(prompt.contains("The user is asking you to write the linked note")); + assert!(prompt.contains("Please write the note for this session.")); + assert_pikchr_note_guidance(&prompt, &remote_path); } #[test] diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 7b6343498..7841e82d3 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2908,8 +2908,13 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val session_commands::build_project_session_context(&store, &project, None); let is_remote = project.location == store::ProjectLocation::Remote; + let pikchr_grammar_reference = + session_commands::resolve_pikchr_grammar_reference(app_handle, None); let action_instructions = - session_commands::build_project_session_action_instructions(is_remote); + session_commands::build_project_session_action_instructions_with_pikchr_reference( + is_remote, + &pikchr_grammar_reference, + ); let full_prompt = format!( "<action>\n{action_instructions}\n\nProject information:\n{project_context}\n</action>\n\n{prompt}" diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index d588456f1..a632f8d2b 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -43,6 +43,9 @@ "icons/icon.icns", "icons/icon.ico" ], + "resources": [ + "resources/pikchr/grammar.md" + ], "macOS": { "infoPlist": "Info.plist" } diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css index 157783324..e491cc3d6 100644 --- a/apps/staged/src/app.css +++ b/apps/staged/src/app.css @@ -90,6 +90,8 @@ --ui-danger-bg: rgba(248, 81, 73, 0.1); --ui-selection: rgba(255, 255, 255, 0.08); + --diagram-canvas-bg: #ffffff; + --scrollbar-thumb: #47424d; --scrollbar-thumb-hover: #5d5962; --scrollbar-thumb-transparent: rgba(255, 255, 255, 0.15); diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 7789947c4..44259f10d 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -6,6 +6,7 @@ describe('browser-native command wrappers', () => { }); afterEach(() => { + vi.doUnmock('./transport'); vi.unstubAllGlobals(); }); @@ -60,4 +61,23 @@ describe('browser-native command wrappers', () => { await expect(openInApp('/tmp/repo', 'finder')).rejects.toThrow('web mode'); expect(fetch).not.toHaveBeenCalled(); }); + + it('builds note follow-up prompts through the backend command', async () => { + const invokeCommand = vi.fn().mockResolvedValue('backend prompt'); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { buildNoteFollowupMessage } = await import('./commands'); + + await expect(buildNoteFollowupMessage('session-1', 'branch-1', true)).resolves.toBe( + 'backend prompt' + ); + expect(invokeCommand).toHaveBeenCalledWith('build_note_followup_message', { + sessionId: 'session-1', + branchId: 'branch-1', + hasParsedNote: true, + }); + }); }); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index f6218eb5b..72e92f09c 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -639,6 +639,18 @@ export function resumeSession( }); } +export function buildNoteFollowupMessage( + sessionId: string, + branchId: string | null | undefined, + hasParsedNote: boolean +): Promise<string> { + return invokeCommand('build_note_followup_message', { + sessionId, + branchId: branchId ?? null, + hasParsedNote, + }); +} + export function cancelSession(sessionId: string): Promise<void> { return invokeCommand('cancel_session', { sessionId }); } diff --git a/apps/staged/src/lib/features/notes/NoteModal.svelte b/apps/staged/src/lib/features/notes/NoteModal.svelte index 0dd7c955e..ee4855694 100644 --- a/apps/staged/src/lib/features/notes/NoteModal.svelte +++ b/apps/staged/src/lib/features/notes/NoteModal.svelte @@ -11,19 +11,18 @@ import Check from '@lucide/svelte/icons/check'; import MessageCircle from '@lucide/svelte/icons/message-circle'; import FileText from '@lucide/svelte/icons/file-text'; - import { marked } from 'marked'; import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { sanitize } from '../../shared/sanitize'; import { countAssistantMessagesAfter, handleExternalLinkClick } from '../../api/commands'; import { formatChatButtonLabel } from '../sessions/noteFreshness'; import InContentSearch from '../../shared/InContentSearch.svelte'; import { highlightMatches, clearHighlights, scrollToMatch } from '../../shared/textHighlight'; import { registerSearchShortcutTarget } from '../keyboard/searchTargets'; import { viewport } from '../../shared/viewport.svelte'; - import { noteMarkdownWithTitle } from './noteMarkdown'; - - marked.setOptions({ breaks: true, gfm: true }); + import '../../shared/markdown/diagramStyles.css'; + import { extractMarkdownDiagramFences } from '../../shared/markdown/diagramFormats'; + import { loadPikchrRenderer, type PikchrRenderer } from '../../shared/markdown/pikchrRendering'; + import { noteMarkdownWithTitle, renderNoteMarkdown } from './noteMarkdown'; interface Props { open: boolean; @@ -58,6 +57,14 @@ let canOpenSession = $derived(Boolean(sessionId && onOpenSession)); let showChatInfo = $derived(canOpenSession && assistantMessagesAfterNote > 0); let noteMarkdown = $derived(noteMarkdownWithTitle(title, content)); + let noteHasPikchr = $derived( + extractMarkdownDiagramFences(noteMarkdown).some((diagram) => diagram.language === 'pikchr') + ); + let pikchrRenderer = $state<PikchrRenderer | null>(null); + let pikchrRendererLoadKey = $derived(noteMarkdown); + let pikchrRendererLoadFailedKey = $state<string | null>(null); + let pikchrRendererLoadFailed = $derived(pikchrRendererLoadFailedKey === pikchrRendererLoadKey); + let renderedNoteHtml = $derived(renderNoteMarkdown(noteMarkdown, { pikchrRenderer })); // Search state let searchVisible = $state(false); @@ -85,6 +92,30 @@ }; }); + $effect(() => { + if (!open || !noteHasPikchr || pikchrRenderer || pikchrRendererLoadFailed) return; + + const loadKey = pikchrRendererLoadKey; + let stale = false; + loadPikchrRenderer() + .then((renderer) => { + if (!stale && pikchrRendererLoadKey === loadKey) pikchrRenderer = renderer; + }) + .catch(() => { + if (!stale && pikchrRendererLoadKey === loadKey) pikchrRendererLoadFailedKey = loadKey; + }); + + return () => { + stale = true; + }; + }); + + $effect(() => { + if (!open) { + pikchrRendererLoadFailedKey = null; + } + }); + onDestroy(() => { unregisterSearchTarget?.(); }); @@ -113,10 +144,6 @@ }; }); - function renderMarkdown(text: string): string { - return sanitize(marked.parse(text) as string); - } - async function handleShare() { try { await navigator.clipboard.writeText(noteMarkdown); @@ -276,7 +303,7 @@ <div class="modal-content" bind:this={contentEl} onclick={handleExternalLinkClick}> {#if noteMarkdown.trim()} <div class="markdown-content"> - {@html renderMarkdown(noteMarkdown)} + {@html renderedNoteHtml} </div> {:else} <p class="empty-note">This note has no content.</p> diff --git a/apps/staged/src/lib/features/notes/diagramFormats.ts b/apps/staged/src/lib/features/notes/diagramFormats.ts new file mode 100644 index 000000000..13d9ccb2e --- /dev/null +++ b/apps/staged/src/lib/features/notes/diagramFormats.ts @@ -0,0 +1,12 @@ +export { + extractMarkdownDiagramFences as extractNoteDiagramFences, + getMarkdownDiagramFormat as getNoteDiagramFormat, + isMarkdownDiagramFormat as isNoteDiagramFormat, + MARKDOWN_DIAGRAM_FORMATS as NOTE_DIAGRAM_FORMATS, + normalizeDiagramFenceLanguage, +} from '../../shared/markdown/diagramFormats'; +export type { + MarkdownDiagramFence as NoteDiagramFence, + MarkdownDiagramFormat as NoteDiagramFormat, + MarkdownDiagramLanguage as NoteDiagramLanguage, +} from '../../shared/markdown/diagramFormats'; diff --git a/apps/staged/src/lib/features/notes/diagramRendering.ts b/apps/staged/src/lib/features/notes/diagramRendering.ts new file mode 100644 index 000000000..33aa304d8 --- /dev/null +++ b/apps/staged/src/lib/features/notes/diagramRendering.ts @@ -0,0 +1,5 @@ +export { renderMarkdownDiagramCodeBlock as renderNoteDiagramCodeBlock } from '../../shared/markdown/diagramRendering'; +export type { + MarkdownDiagramRenderingOptions as NoteDiagramRenderingOptions, + RenderedMarkdownDiagramCodeBlock as RenderedNoteDiagramCodeBlock, +} from '../../shared/markdown/diagramRendering'; diff --git a/apps/staged/src/lib/features/notes/noteMarkdown.test.ts b/apps/staged/src/lib/features/notes/noteMarkdown.test.ts index fd25b4a4e..cee12ef2e 100644 --- a/apps/staged/src/lib/features/notes/noteMarkdown.test.ts +++ b/apps/staged/src/lib/features/notes/noteMarkdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { noteMarkdownWithTitle } from './noteMarkdown'; +import { noteMarkdownWithTitle, renderNoteMarkdown } from './noteMarkdown'; describe('noteMarkdownWithTitle', () => { it('prepends the note title as a markdown H1', () => { @@ -23,3 +23,12 @@ describe('noteMarkdownWithTitle', () => { expect(noteMarkdownWithTitle('', 'Body text.')).toBe('Body text.'); }); }); + +describe('renderNoteMarkdown', () => { + it('uses the shared markdown renderer', () => { + const html = renderNoteMarkdown('```pikchr\nbox "Start" fit\n```'); + + expect(html).toContain('<pre class="markdown-diagram-source markdown-diagram-source-pikchr">'); + expect(html).toContain('box "Start" fit'); + }); +}); diff --git a/apps/staged/src/lib/features/notes/noteMarkdown.ts b/apps/staged/src/lib/features/notes/noteMarkdown.ts index 64b2c108c..32c688c1e 100644 --- a/apps/staged/src/lib/features/notes/noteMarkdown.ts +++ b/apps/staged/src/lib/features/notes/noteMarkdown.ts @@ -1,3 +1,8 @@ +import { + renderMarkdown, + type MarkdownRenderingOptions, +} from '../../shared/markdown/renderMarkdown'; + export function noteMarkdownWithTitle(title: string, content: string): string { const normalizedTitle = title.trim(); if (!normalizedTitle) return content; @@ -12,3 +17,7 @@ export function noteMarkdownWithTitle(title: string, content: string): string { function startsWithMarkdownH1(content: string): boolean { return /^#[ \t]+\S/.test(content); } + +export function renderNoteMarkdown(text: string, options: MarkdownRenderingOptions = {}): string { + return renderMarkdown(text, options); +} diff --git a/apps/staged/src/lib/features/notes/pikchrRendering.ts b/apps/staged/src/lib/features/notes/pikchrRendering.ts new file mode 100644 index 000000000..50f86349e --- /dev/null +++ b/apps/staged/src/lib/features/notes/pikchrRendering.ts @@ -0,0 +1,9 @@ +export { + loadPikchrRenderer as loadNotePikchrRenderer, + renderPikchrSource, + sanitizePikchrSvg, +} from '../../shared/markdown/pikchrRendering'; +export type { + PikchrRenderer as NotePikchrRenderer, + RenderedPikchrDiagram, +} from '../../shared/markdown/pikchrRendering'; diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 2b6d789f5..4f219a092 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -42,11 +42,10 @@ import ImagePlus from '@lucide/svelte/icons/image-plus'; import Plus from '@lucide/svelte/icons/plus'; import Spinner from '../../shared/Spinner.svelte'; - import { marked } from 'marked'; - import { sanitize } from '../../shared/sanitize'; import { isResumableReason } from '../../types'; import type { Session, SessionMessage, HashtagItem, ProjectRepo } from '../../types'; import { + buildNoteFollowupMessage, cancelSession, createImage, createImageFromData, @@ -92,14 +91,11 @@ import { highlightMatches, clearHighlights, scrollToMatch } from '../../shared/textHighlight'; import { registerSearchShortcutTarget } from '../keyboard/searchTargets'; import { viewport } from '../../shared/viewport.svelte'; - import { - buildNoteFollowupMessage, - getNoteFollowupLabel, - type LinkedNoteContext, - } from './noteFreshness'; - - // Configure marked - marked.setOptions({ breaks: true, gfm: true }); + import '../../shared/markdown/diagramStyles.css'; + import { extractMarkdownDiagramFences } from '../../shared/markdown/diagramFormats'; + import { renderMarkdown as renderSharedMarkdown } from '../../shared/markdown/renderMarkdown'; + import { loadPikchrRenderer, type PikchrRenderer } from '../../shared/markdown/pikchrRendering'; + import { getNoteFollowupLabel, type LinkedNoteContext } from './noteFreshness'; interface Props { open: boolean; @@ -118,6 +114,11 @@ onOpenNote?: (note: LinkedNoteContext) => void; } + type SendMessageTarget = { + sessionId: string; + branchId: string | null; + }; + let { open, sessionId, @@ -156,6 +157,21 @@ let isLive = $derived(session?.status === 'running'); let hasQueuedMessages = $derived(messageQueue.length > 0); let noteFollowupLabel = $derived(getNoteFollowupLabel(session, messages, noteInfo)); + let assistantMarkdownContent = $derived( + messages + .filter((message) => message.role === 'assistant') + .map((message) => message.content) + .join('\n\n') + ); + let sessionHasPikchr = $derived( + extractMarkdownDiagramFences(assistantMarkdownContent).some( + (diagram) => diagram.language === 'pikchr' + ) + ); + let pikchrRenderer = $state<PikchrRenderer | null>(null); + let pikchrRendererLoadKey = $derived(`${sessionId}\0${assistantMarkdownContent}`); + let pikchrRendererLoadFailedKey = $state<string | null>(null); + let pikchrRendererLoadFailed = $derived(pikchrRendererLoadFailedKey === pikchrRendererLoadKey); const SLIDE_DURATION = 150; @@ -561,12 +577,20 @@ } /** Actually send a message to the backend and start the agent. */ - async function sendMessage(text: string, imageIds?: string[]) { - if (!session || sending) return; - sending = true; + async function sendMessage( + text: string, + imageIds?: string[], + sendingLocked = false, + target: SendMessageTarget | null = null + ) { + const targetSessionId = target?.sessionId ?? session?.id; + const targetBranchId = target ? target.branchId : (branchId ?? null); + if (!targetSessionId || session?.id !== targetSessionId || (!sendingLocked && sending)) return; + if (!sendingLocked) sending = true; error = null; try { - await resumeSession(session.id, text, imageIds, branchId); + await resumeSession(targetSessionId, text, imageIds, targetBranchId); + if (session?.id !== targetSessionId) return; // Backend sets status to running and emits an event. // Force an immediate poll to pick up the new user message + status. session = { ...session, status: 'running' }; @@ -577,13 +601,30 @@ // Clear the queue — don't keep trying to send if the session is broken messageQueue = []; } finally { - sending = false; + if (!sendingLocked) sending = false; } } - function handleNoteFollowupClick() { - if (!noteInfo || sending) return; - void sendMessage(buildNoteFollowupMessage(noteInfo.hasParsedNote)); + async function handleNoteFollowupClick() { + if (!session || !noteInfo || sending) return; + const target: SendMessageTarget = { sessionId: session.id, branchId: branchId ?? null }; + const hasParsedNote = noteInfo.hasParsedNote; + sending = true; + error = null; + try { + const prompt = await buildNoteFollowupMessage( + target.sessionId, + target.branchId, + hasParsedNote + ); + if (session?.id !== target.sessionId || (branchId ?? null) !== target.branchId) return; + await sendMessage(prompt, undefined, true, target); + } catch (e) { + error = `Failed to send: ${e instanceof Error ? e.message : String(e)}`; + messageQueue = []; + } finally { + sending = false; + } } /** Process the next queued message when the session becomes idle. */ @@ -721,7 +762,7 @@ } function renderMarkdown(content: string): string { - return sanitize(marked.parse(content) as string); + return renderSharedMarkdown(content, { pikchrRenderer }); } /** Memoized wrapper around the shared renderHashtagTokens. */ @@ -903,6 +944,30 @@ } }); + $effect(() => { + if (!open || !sessionHasPikchr || pikchrRenderer || pikchrRendererLoadFailed) return; + + const loadKey = pikchrRendererLoadKey; + let stale = false; + loadPikchrRenderer() + .then((renderer) => { + if (!stale && pikchrRendererLoadKey === loadKey) pikchrRenderer = renderer; + }) + .catch(() => { + if (!stale && pikchrRendererLoadKey === loadKey) pikchrRendererLoadFailedKey = loadKey; + }); + + return () => { + stale = true; + }; + }); + + $effect(() => { + if (!open) { + pikchrRendererLoadFailedKey = null; + } + }); + function requestClose() { if (closed) return; closed = true; diff --git a/apps/staged/src/lib/features/sessions/noteFreshness.test.ts b/apps/staged/src/lib/features/sessions/noteFreshness.test.ts index a0c471eea..26b506eb3 100644 --- a/apps/staged/src/lib/features/sessions/noteFreshness.test.ts +++ b/apps/staged/src/lib/features/sessions/noteFreshness.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Session, SessionMessage, SessionStatus, CompletionReason } from '../../types'; import { - buildNoteFollowupMessage, countAssistantMessagesAfterNote, formatChatButtonLabel, getNoteFollowupLabel, @@ -125,19 +124,18 @@ describe('note freshness', () => { expect(latestAssistantMessage(messages)?.id).toBe(3); }); - it('builds a structured follow-up prompt with a readable visible request', () => { - const updateMessage = buildNoteFollowupMessage(true); - const writeMessage = buildNoteFollowupMessage(false); + it('recognizes a backend-built follow-up prompt by marker text', () => { + const backendBuiltFollowup = + '<action>\nThe user is asking you to update the linked note from the latest chat history.\n</action>'; - expect(updateMessage.startsWith('<action>')).toBe(true); - expect(updateMessage).toContain('```suggested-next-steps'); - expect(updateMessage).toContain('\n---\n# <Title>'); - expect(updateMessage).toContain('Please update the note to reflect the latest chat.'); - expect(writeMessage).toContain('Please write the note for this session.'); + expect(hasNoteFollowupBeenSent([message(1, 'user', 2500, backendBuiltFollowup)], 2000)).toBe( + true + ); }); it('suppresses note followup CTA when a followup was already sent after note updatedAt', () => { - const followupContent = buildNoteFollowupMessage(true); + const followupContent = + '<action>\nThe user is asking you to update the linked note from the latest chat history.\n</action>'; const messages = [ message(1, 'assistant', 1500), message(2, 'user', 2500, followupContent), @@ -160,7 +158,8 @@ describe('note freshness', () => { it('does not suppress CTA when followup was sent before note was updated', () => { // A followup was sent at t=1500, but the note was updated at t=2000 (after the followup). // New assistant messages at t=3000 should still trigger the CTA. - const followupContent = buildNoteFollowupMessage(true); + const followupContent = + '<action>\nThe user is asking you to update the linked note from the latest chat history.\n</action>'; const messages = [ message(1, 'assistant', 1000), message(2, 'user', 1500, followupContent), diff --git a/apps/staged/src/lib/features/sessions/noteFreshness.ts b/apps/staged/src/lib/features/sessions/noteFreshness.ts index 414c74deb..888518015 100644 --- a/apps/staged/src/lib/features/sessions/noteFreshness.ts +++ b/apps/staged/src/lib/features/sessions/noteFreshness.ts @@ -94,37 +94,3 @@ export function getNoteFollowupLabel( ? 'Ask for the note to be updated' : 'Ask for a note to be written'; } - -export function buildNoteFollowupMessage(hasParsedNote: boolean): string { - const visibleRequest = hasParsedNote - ? 'Please update the note to reflect the latest chat.' - : 'Please write the note for this session.'; - - return `<action> -The user is asking you to ${hasParsedNote ? 'update the linked note' : 'write the linked note'} from the latest chat history. - -Use the existing conversation context. Do not create commits. - -Your final response must include a suggested-next-steps fenced block followed by the note content after a horizontal rule: - -\`\`\`suggested-next-steps -{"suggestedNextCommitStep": null, "suggestedNextNoteStep": null} -\`\`\` - ---- -# <Title> -<Body> - -Formatting requirements: -- The opening fence line for suggested-next-steps must be exactly: \`\`\`suggested-next-steps -- The closing fence line must be exactly: \`\`\` -- Put only a JSON object inside the suggested-next-steps block. -- Include both nullable string fields: suggestedNextCommitStep and suggestedNextNoteStep. -- Keep suggested next steps concise; use null when there is no clear next action. -- The \`---\` separator must be on its own line. -- The note content must start immediately after \`---\` with a markdown H1. -- Do not wrap the note in code fences. -</action> - -${visibleRequest}`; -} diff --git a/apps/staged/src/lib/shared/markdown/diagramFormats.test.ts b/apps/staged/src/lib/shared/markdown/diagramFormats.test.ts new file mode 100644 index 000000000..387f069e8 --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/diagramFormats.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractMarkdownDiagramFences, + getMarkdownDiagramFormat, + isMarkdownDiagramFormat, + MARKDOWN_DIAGRAM_FORMATS, + normalizeDiagramFenceLanguage, +} from './diagramFormats'; + +describe('markdown diagram format registry', () => { + it('registers only Pikchr as the recommended general diagram format', () => { + expect(MARKDOWN_DIAGRAM_FORMATS).toEqual([ + { + language: 'pikchr', + displayName: 'Pikchr', + role: 'general', + recommended: true, + }, + ]); + }); + + it('recognizes Pikchr fence languages case-insensitively', () => { + expect(getMarkdownDiagramFormat('PIKCHR')?.language).toBe('pikchr'); + expect(getMarkdownDiagramFormat('mermaid')).toBeNull(); + expect(getMarkdownDiagramFormat('svg')).toBeNull(); + expect(getMarkdownDiagramFormat('typescript')).toBeNull(); + }); + + it('normalizes the first token from a fence info string', () => { + expect(normalizeDiagramFenceLanguage(' Pikchr title="draft" ')).toBe('pikchr'); + expect(normalizeDiagramFenceLanguage('')).toBeNull(); + }); + + it('reports whether a fence language is a known markdown diagram format', () => { + expect(isMarkdownDiagramFormat('pikchr')).toBe(true); + expect(isMarkdownDiagramFormat('rust')).toBe(false); + }); +}); + +describe('extractMarkdownDiagramFences', () => { + it('extracts Pikchr fence metadata and source', () => { + const diagrams = extractMarkdownDiagramFences( + [ + '# Note', + '', + '```pikchr title="State flow"', + 'box "Start" fit', + 'arrow right 150%', + 'circle "State" fit', + '```', + ].join('\n') + ); + + expect(diagrams).toEqual([ + expect.objectContaining({ + language: 'pikchr', + infoString: 'pikchr title="State flow"', + source: 'box "Start" fit\narrow right 150%\ncircle "State" fit', + startLine: 3, + endLine: 7, + }), + ]); + }); + + it('ignores Mermaid and SVG fences as ordinary code fences', () => { + const diagrams = extractMarkdownDiagramFences( + ['```mermaid', 'flowchart TD', '```', '~~~svg', '<svg></svg>', '~~~'].join('\n') + ); + + expect(diagrams).toEqual([]); + }); + + it('ignores non-diagram fences while skipping their contents', () => { + const diagrams = extractMarkdownDiagramFences( + ['```ts', 'const sample = "```pikchr";', '```', '```pikchr', 'box "Done" fit', '```'].join( + '\n' + ) + ); + + expect(diagrams).toHaveLength(1); + expect(diagrams[0].source).toBe('box "Done" fit'); + }); + + it('handles an unclosed diagram fence through the end of markdown', () => { + const diagrams = extractMarkdownDiagramFences( + ['Before', '```pikchr', 'box "Draft" fit'].join('\n') + ); + + expect(diagrams).toEqual([ + expect.objectContaining({ + language: 'pikchr', + source: 'box "Draft" fit', + startLine: 2, + endLine: null, + }), + ]); + }); +}); diff --git a/apps/staged/src/lib/shared/markdown/diagramFormats.ts b/apps/staged/src/lib/shared/markdown/diagramFormats.ts new file mode 100644 index 000000000..844a0ffbc --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/diagramFormats.ts @@ -0,0 +1,94 @@ +export const MARKDOWN_DIAGRAM_FORMATS = [ + { + language: 'pikchr', + displayName: 'Pikchr', + role: 'general', + recommended: true, + }, +] as const; + +export type MarkdownDiagramFormat = (typeof MARKDOWN_DIAGRAM_FORMATS)[number]; +export type MarkdownDiagramLanguage = MarkdownDiagramFormat['language']; + +export interface MarkdownDiagramFence { + format: MarkdownDiagramFormat; + language: MarkdownDiagramLanguage; + infoString: string; + source: string; + startLine: number; + endLine: number | null; +} + +const FORMATS_BY_LANGUAGE = new Map<MarkdownDiagramLanguage, MarkdownDiagramFormat>( + MARKDOWN_DIAGRAM_FORMATS.map((format) => [format.language, format]) +); + +export function normalizeDiagramFenceLanguage( + infoString: string | null | undefined +): string | null { + const language = infoString?.trim().split(/\s+/, 1)[0]?.toLowerCase(); + return language || null; +} + +export function getMarkdownDiagramFormat( + infoString: string | null | undefined +): MarkdownDiagramFormat | null { + const language = normalizeDiagramFenceLanguage(infoString); + if (!language) return null; + + return FORMATS_BY_LANGUAGE.get(language as MarkdownDiagramLanguage) ?? null; +} + +export function isMarkdownDiagramFormat(infoString: string | null | undefined): boolean { + return getMarkdownDiagramFormat(infoString) !== null; +} + +export function extractMarkdownDiagramFences(markdown: string): MarkdownDiagramFence[] { + const lines = markdown.split(/\r\n|\n|\r/); + const diagrams: MarkdownDiagramFence[] = []; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const opening = lines[lineIndex].match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/); + if (!opening) continue; + + const openingFence = opening[1]; + const fenceChar = openingFence[0]; + const fenceLength = openingFence.length; + const infoString = opening[2].trim(); + const contentStart = lineIndex + 1; + let closingLineIndex: number | null = null; + + for ( + let candidateLineIndex = contentStart; + candidateLineIndex < lines.length; + candidateLineIndex++ + ) { + if (isClosingFence(lines[candidateLineIndex], fenceChar, fenceLength)) { + closingLineIndex = candidateLineIndex; + break; + } + } + + const contentEnd = closingLineIndex ?? lines.length; + const format = getMarkdownDiagramFormat(infoString); + if (format) { + diagrams.push({ + format, + language: format.language, + infoString, + source: lines.slice(contentStart, contentEnd).join('\n'), + startLine: lineIndex + 1, + endLine: closingLineIndex === null ? null : closingLineIndex + 1, + }); + } + + lineIndex = closingLineIndex ?? lines.length; + } + + return diagrams; +} + +function isClosingFence(line: string, fenceChar: string, fenceLength: number): boolean { + const escapedFenceChar = fenceChar === '`' ? '`' : '\\~'; + return new RegExp(`^ {0,3}${escapedFenceChar}{${fenceLength},}[ \\t]*$`).test(line); +} diff --git a/apps/staged/src/lib/shared/markdown/diagramRendering.ts b/apps/staged/src/lib/shared/markdown/diagramRendering.ts new file mode 100644 index 000000000..58e259533 --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/diagramRendering.ts @@ -0,0 +1,54 @@ +import type { Tokens } from 'marked'; + +import { getMarkdownDiagramFormat, type MarkdownDiagramFormat } from './diagramFormats'; +import { sanitizePikchrSvg, type PikchrRenderer } from './pikchrRendering'; + +export interface MarkdownDiagramRenderingOptions { + pikchrRenderer?: PikchrRenderer | null; +} + +export interface RenderedMarkdownDiagramCodeBlock { + html: string; + trustedHtml: boolean; +} + +export function renderMarkdownDiagramCodeBlock( + token: Tokens.Code, + renderedSource: string, + options: MarkdownDiagramRenderingOptions = {} +): RenderedMarkdownDiagramCodeBlock | null { + const format = getMarkdownDiagramFormat(token.lang); + if (!format) return null; + + const renderedDiagramSource = withDiagramSourceClass(renderedSource, format); + const renderedPikchr = options.pikchrRenderer?.(token.text); + if (!renderedPikchr || renderedPikchr.kind !== 'svg') { + return { html: renderedDiagramSource, trustedHtml: false }; + } + const renderedSvg = sanitizePikchrSvg(renderedPikchr.svg); + if (!renderedSvg) { + return { html: renderedDiagramSource, trustedHtml: false }; + } + + return { + html: renderPikchrPreview(renderedSvg), + trustedHtml: true, + }; +} + +function withDiagramSourceClass(renderedSource: string, format: MarkdownDiagramFormat): string { + return renderedSource.replace( + '<pre>', + `<pre class="markdown-diagram-source markdown-diagram-source-${format.language}">` + ); +} + +function renderPikchrPreview(renderedSvg: string): string { + return [ + '<figure class="markdown-diagram markdown-diagram-pikchr">', + '<div class="markdown-diagram-preview markdown-diagram-preview-pikchr">', + renderedSvg, + '</div>', + '</figure>', + ].join(''); +} diff --git a/apps/staged/src/lib/shared/markdown/diagramStyles.css b/apps/staged/src/lib/shared/markdown/diagramStyles.css new file mode 100644 index 000000000..de4c6772f --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/diagramStyles.css @@ -0,0 +1,25 @@ +.markdown-content .markdown-diagram { + margin: 0.9em 0; + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; + background: var(--bg-primary); +} + +.markdown-content .markdown-diagram-preview { + display: flex; + justify-content: center; + padding: 16px; + background: var(--diagram-canvas-bg); + overflow-x: auto; +} + +.markdown-content .markdown-diagram-preview svg { + display: block; + max-width: 100%; + height: auto; +} + +.markdown-content .markdown-diagram-preview-pikchr .markdown-pikchr-svg { + overflow: visible; +} diff --git a/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts b/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts new file mode 100644 index 000000000..4c56dcec9 --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { loadPikchrRenderer, sanitizePikchrSvg } from './pikchrRendering'; + +describe('sanitizePikchrSvg', () => { + it('keeps static Pikchr geometry and path styles', () => { + const svg = sanitizePikchrSvg( + [ + '<svg xmlns="http://www.w3.org/2000/svg" class="markdown-pikchr-svg" viewBox="0 0 58 34" data-pikchr-date="20260403102956">', + '<path d="M2,32L56,32L56,2L2,2Z" style="fill:none;stroke-width:2.16;stroke:rgb(0,0,0);" />', + '<text x="29" y="17" text-anchor="middle" fill="rgb(0,0,0)" dominant-baseline="central">Start</text>', + '</svg>', + ].join('') + ); + + expect(svg).toContain('<svg'); + expect(svg).toContain('viewBox="0 0 58 34"'); + expect(svg).toContain('<path'); + expect(svg).toContain('style="fill:none;stroke-width:2.16;stroke:rgb(0,0,0)"'); + expect(svg).toContain('<text'); + expect(svg).toContain('fill="rgb(0,0,0)"'); + expect(svg).not.toContain('data-pikchr-date'); + }); + + it('keeps safe direct SVG colors and strips unsafe direct SVG colors', () => { + const svg = sanitizePikchrSvg( + [ + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">', + '<path d="M1,1L19,19" fill="none" stroke="#123456" />', + '<text x="10" y="10" fill="rgb(1, 2, 3)" stroke="url(https://example.com/stroke)">Label</text>', + '<rect x="1" y="1" width="4" height="4" fill="url(https://example.com/fill)" stroke="rgba(12, 34, 56, 0.5)" />', + '</svg>', + ].join('') + ); + + expect(svg).toContain('fill="none"'); + expect(svg).toContain('stroke="#123456"'); + expect(svg).toContain('fill="rgb(1, 2, 3)"'); + expect(svg).toContain('stroke="rgba(12, 34, 56, 0.5)"'); + expect(svg).not.toContain('url('); + }); + + it('adds breathing room to side-anchored Pikchr text labels', () => { + const svg = sanitizePikchrSvg( + [ + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">', + '<text x="20" y="20" text-anchor="start">Right-side label</text>', + '<text x="20" y="40" text-anchor="end">Left-side label</text>', + '<text x="20" y="60" text-anchor="start" dx="1em">Custom label</text>', + '<text x="60" y="20" text-anchor="middle">Centered label</text>', + '</svg>', + ].join('') + ); + + expect(svg).toMatch( + /<text\b(?=[^>]*text-anchor="start")(?=[^>]*dx="0\.35em")[^>]*>Right-side label<\/text>/ + ); + expect(svg).toMatch( + /<text\b(?=[^>]*text-anchor="end")(?=[^>]*dx="-0\.35em")[^>]*>Left-side label<\/text>/ + ); + expect(svg).toMatch( + /<text\b(?=[^>]*text-anchor="start")(?=[^>]*dx="1em")[^>]*>Custom label<\/text>/ + ); + expect(svg).toMatch( + /<text\b(?=[^>]*text-anchor="middle")(?![^>]*\bdx=)[^>]*>Centered label<\/text>/ + ); + }); + + it('strips executable and external-resource SVG surface', () => { + const svg = sanitizePikchrSvg( + [ + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" onclick="alert(1)">', + '<script>alert(1)</script>', + '<foreignObject><iframe src="https://example.com"></iframe></foreignObject>', + '<path d="M0,0L10,10" style="stroke:url(https://example.com/x);fill:none;" />', + '<text x="1" y="1" href="https://example.com">Label</text>', + '</svg>', + ].join('') + ); + + expect(svg).toContain('<svg'); + expect(svg).not.toContain('<script'); + expect(svg).not.toContain('foreignObject'); + expect(svg).not.toContain('iframe'); + expect(svg).not.toContain('onclick'); + expect(svg).not.toContain('href'); + expect(svg).not.toContain('url('); + }); + + it('rejects non-SVG renderer output', () => { + expect(sanitizePikchrSvg('<div><pre>ERROR</pre></div>')).toBeNull(); + }); +}); + +describe('loadPikchrRenderer', () => { + it('loads the bundled renderer and returns sanitized SVG', async () => { + const renderPikchr = await loadPikchrRenderer(); + const rendered = renderPikchr('box "Start" fit'); + + expect(rendered.kind).toBe('svg'); + if (rendered.kind !== 'svg') return; + + expect(rendered.width).toBeGreaterThan(0); + expect(rendered.height).toBeGreaterThan(0); + expect(rendered.svg).toContain('<svg'); + expect(rendered.svg).toContain('class="markdown-pikchr-svg"'); + expect(rendered.svg).toContain('<path'); + expect(rendered.svg).toContain('Start'); + expect(rendered.svg).not.toContain('<script'); + expect(rendered.svg).not.toContain('data-pikchr-date'); + }); + + it('retries loading after renderer initialization fails', async () => { + const loadPikchr = vi + .fn() + .mockRejectedValueOnce(new Error('initialization failed')) + .mockResolvedValueOnce({ + render: () => ({ + width: 10, + height: 10, + svg: '<svg xmlns="http://www.w3.org/2000/svg" class="markdown-pikchr-svg" viewBox="0 0 10 10"><path d="M0,0L10,10" /></svg>', + }), + }); + + vi.resetModules(); + vi.doMock('pikchr-js', () => ({ default: loadPikchr })); + + try { + const { loadPikchrRenderer } = await import('./pikchrRendering'); + + await expect(loadPikchrRenderer()).rejects.toThrow('initialization failed'); + + const renderPikchr = await loadPikchrRenderer(); + const rendered = renderPikchr('box "Retry" fit'); + + expect(loadPikchr).toHaveBeenCalledTimes(2); + expect(rendered).toMatchObject({ kind: 'svg', width: 10, height: 10 }); + } finally { + vi.doUnmock('pikchr-js'); + vi.resetModules(); + } + }); +}); diff --git a/apps/staged/src/lib/shared/markdown/pikchrRendering.ts b/apps/staged/src/lib/shared/markdown/pikchrRendering.ts new file mode 100644 index 000000000..ee52a80cb --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/pikchrRendering.ts @@ -0,0 +1,185 @@ +import type { Pikchr } from 'pikchr-js'; +import sanitizeHtml from 'sanitize-html'; + +const PIKCHR_SVG_CLASS = 'markdown-pikchr-svg'; +const MAX_PIKCHR_SOURCE_LENGTH = 20_000; +const MAX_PIKCHR_SVG_LENGTH = 250_000; +const PIKCHR_SVG_ROOT = /^\s*<svg[\s>]/i; +const PIKCHR_SIDE_LABEL_GAP = '0.35em'; + +const CSS_NUMBER = String.raw`[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[-+]?\d+)?`; +const CSS_LENGTH = new RegExp(`^(?:${CSS_NUMBER})(?:px|pt|pc|mm|cm|in|em|rem|%)?$`); +const CSS_COLOR = + /^(?:none|transparent|currentColor|[a-zA-Z]+|#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/; +const CSS_DASH_ARRAY = new RegExp(`^(?:${CSS_NUMBER})(?:\\s*,\\s*(?:${CSS_NUMBER}))*$`); +const DIRECT_COLOR_ATTRIBUTES = ['fill', 'stroke'] as const; +const DIRECT_COLOR_ATTRIBUTE_TAGS = new Set([ + 'path', + 'text', + 'rect', + 'circle', + 'ellipse', + 'line', + 'polyline', + 'polygon', +]); + +export type RenderedPikchrDiagram = + | { + kind: 'svg'; + svg: string; + width: number; + height: number; + } + | { + kind: 'error'; + message: string; + }; + +export type PikchrRenderer = (source: string) => RenderedPikchrDiagram; + +let rendererPromise: Promise<PikchrRenderer> | null = null; + +export function loadPikchrRenderer(): Promise<PikchrRenderer> { + rendererPromise ??= import('pikchr-js') + .then(({ default: loadPikchr }) => loadPikchr()) + .then((pikchr) => { + return (source: string) => renderPikchrSource(pikchr, source); + }) + .catch((error) => { + rendererPromise = null; + throw error; + }); + return rendererPromise; +} + +export function renderPikchrSource(pikchr: Pikchr, source: string): RenderedPikchrDiagram { + if (source.length > MAX_PIKCHR_SOURCE_LENGTH) { + return { kind: 'error', message: 'Pikchr source is too large to render safely.' }; + } + + try { + const rendered = pikchr.render(source, PIKCHR_SVG_CLASS); + if (rendered.width < 0 || rendered.height < 0 || !PIKCHR_SVG_ROOT.test(rendered.svg)) { + return { kind: 'error', message: 'Pikchr could not render this diagram.' }; + } + + const svg = sanitizePikchrSvg(rendered.svg); + if (!svg) { + return { kind: 'error', message: 'Pikchr rendered unsafe SVG.' }; + } + + return { + kind: 'svg', + svg, + width: rendered.width, + height: rendered.height, + }; + } catch { + return { kind: 'error', message: 'Pikchr could not render this diagram.' }; + } +} + +export function sanitizePikchrSvg(svg: string): string | null { + if (svg.length > MAX_PIKCHR_SVG_LENGTH || !PIKCHR_SVG_ROOT.test(svg)) { + return null; + } + + const sanitized = sanitizeHtml(svg, { + allowedTags: [ + 'svg', + 'g', + 'path', + 'text', + 'rect', + 'circle', + 'ellipse', + 'line', + 'polyline', + 'polygon', + ], + allowedAttributes: { + svg: ['xmlns', 'viewBox', 'viewbox', 'class', 'style', 'width', 'height'], + g: ['class', 'transform', 'style'], + path: ['class', 'd', 'fill', 'stroke', 'style'], + text: [ + 'class', + 'x', + 'y', + 'dx', + 'dy', + 'text-anchor', + 'dominant-baseline', + 'fill', + 'stroke', + 'style', + ], + rect: ['class', 'x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'style'], + circle: ['class', 'cx', 'cy', 'r', 'fill', 'stroke', 'style'], + ellipse: ['class', 'cx', 'cy', 'rx', 'ry', 'fill', 'stroke', 'style'], + line: ['class', 'x1', 'y1', 'x2', 'y2', 'fill', 'stroke', 'style'], + polyline: ['class', 'points', 'fill', 'stroke', 'style'], + polygon: ['class', 'points', 'fill', 'stroke', 'style'], + }, + allowedStyles: { + '*': { + fill: [CSS_COLOR], + stroke: [CSS_COLOR], + 'stroke-width': [CSS_LENGTH], + 'stroke-dasharray': [CSS_DASH_ARRAY], + 'font-size': [/^initial$/, CSS_LENGTH], + }, + }, + allowedSchemes: [], + transformTags: { + '*': normalizePikchrSvgAttributes, + }, + }); + + const normalized = sanitized.replace(/\sviewbox=/g, ' viewBox='); + if (!PIKCHR_SVG_ROOT.test(normalized)) return null; + return normalized; +} + +function normalizePikchrSvgAttributes(tagName: string, attribs: Record<string, string>) { + const colorNormalized = stripUnsafeDirectColorAttributes(tagName, attribs); + if (tagName.toLowerCase() !== 'text') return colorNormalized; + + return { + tagName, + attribs: addSideLabelGap(colorNormalized.attribs), + }; +} + +function addSideLabelGap(attribs: Record<string, string>) { + if (attribs.dx !== undefined) return attribs; + + const anchor = attribs['text-anchor']?.trim().toLowerCase(); + if (anchor !== 'start' && anchor !== 'end') return attribs; + + return { + ...attribs, + dx: anchor === 'start' ? PIKCHR_SIDE_LABEL_GAP : `-${PIKCHR_SIDE_LABEL_GAP}`, + }; +} + +function stripUnsafeDirectColorAttributes(tagName: string, attribs: Record<string, string>) { + const nextAttribs = { ...attribs }; + if (!DIRECT_COLOR_ATTRIBUTE_TAGS.has(tagName.toLowerCase())) { + return { tagName, attribs: nextAttribs }; + } + + for (const attribute of DIRECT_COLOR_ATTRIBUTES) { + const value = nextAttribs[attribute]; + if (value === undefined) continue; + + const trimmedValue = value.trim(); + if (CSS_COLOR.test(trimmedValue)) { + nextAttribs[attribute] = trimmedValue; + } else { + delete nextAttribs[attribute]; + } + } + + return { tagName, attribs: nextAttribs }; +} diff --git a/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts b/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts new file mode 100644 index 000000000..0e2030726 --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { renderMarkdown } from './renderMarkdown'; +import type { PikchrRenderer } from './pikchrRendering'; + +describe('renderMarkdown', () => { + it('renders Pikchr fenced blocks as escaped source while the renderer is unavailable', () => { + const html = renderMarkdown('```pikchr\nbox "Start" fit\n```'); + + expect(html).toContain('<pre class="markdown-diagram-source markdown-diagram-source-pikchr">'); + expect(html).toContain('box "Start" fit'); + expect(html).not.toContain('<svg'); + expect(html).not.toContain('markdown-diagram-preview'); + }); + + it('leaves non-diagram fenced blocks as normal code blocks', () => { + const html = renderMarkdown('```ts\nconst value = 1;\n```'); + + expect(html).toContain('<pre><code class="language-ts">'); + expect(html).not.toContain('markdown-diagram-source'); + }); + + it('leaves Mermaid fenced blocks as normal code blocks', () => { + const html = renderMarkdown('```mermaid\nflowchart TD\nA-->B\n```'); + + expect(html).toContain('<pre><code class="language-mermaid">'); + expect(html).toContain('flowchart TD'); + expect(html).not.toContain('markdown-diagram-source'); + expect(html).not.toContain('markdown-diagram-preview'); + }); + + it('renders Pikchr fenced blocks as sanitized SVG when a renderer is loaded', () => { + const html = renderMarkdown('```pikchr\nbox "Start" fit\n```', { + pikchrRenderer: safePikchrRenderer, + }); + + expect(html).toContain('<figure class="markdown-diagram markdown-diagram-pikchr">'); + expect(html).not.toContain('markdown-diagram-caption'); + expect(html).toContain( + '<div class="markdown-diagram-preview markdown-diagram-preview-pikchr">' + ); + expect(html).toContain('<svg'); + expect(html).toContain('class="markdown-pikchr-svg"'); + expect(html).toContain('<path'); + expect(html).toContain('stroke:rgb(0,0,0)'); + expect(html).not.toContain('markdown-diagram-source-wrap'); + expect(html).not.toContain('markdown-diagram-source-pikchr'); + expect(html).not.toContain('box "Start" fit'); + expect(html).not.toContain('box "Start" fit'); + expect(html).not.toContain('STAGED_MARKDOWN_TRUSTED_DIAGRAM_'); + }); + + it('does not allow renderer SVG through the generic Markdown sanitizer', () => { + const html = renderMarkdown('<svg><script>alert(1)</script></svg>'); + + expect(html).not.toContain('<svg'); + expect(html).not.toContain('<script>'); + }); + + it('does not allow raw HTML to opt into markdown diagram classes', () => { + const html = renderMarkdown( + '<div class="markdown-diagram-preview"><figcaption class="markdown-diagram-caption">Pikchr</figcaption></div>' + ); + + expect(html).not.toContain('class="markdown-diagram-preview"'); + expect(html).not.toContain('class="markdown-diagram-caption"'); + expect(html).toContain('Pikchr'); + }); + + it('falls back to escaped source when the Pikchr renderer rejects the SVG', () => { + const html = renderMarkdown('```pikchr\nbox "Unsafe" fit\n```', { + pikchrRenderer: unsafePikchrRenderer, + }); + + expect(html).toContain('<pre class="markdown-diagram-source markdown-diagram-source-pikchr">'); + expect(html).toContain('box "Unsafe" fit'); + expect(html).not.toContain('<svg'); + expect(html).not.toContain('<script>'); + expect(html).not.toContain('onclick'); + }); + + it('leaves raw SVG fenced blocks as normal escaped code blocks', () => { + const html = renderMarkdown('```svg\n<svg><script>alert(1)</script></svg>\n```'); + + expect(html).toContain('<pre><code class="language-svg">'); + expect(html).toContain('<svg><script>alert(1)</script></svg>'); + expect(html).not.toContain('markdown-diagram-source'); + expect(html).not.toContain('<script>'); + }); +}); + +const safePikchrRenderer: PikchrRenderer = () => ({ + kind: 'svg', + width: 58, + height: 34, + svg: [ + '<svg xmlns="http://www.w3.org/2000/svg" class="markdown-pikchr-svg" viewBox="0 0 58 34">', + '<path d="M2,32L56,32L56,2L2,2Z" style="fill:none;stroke-width:2.16;stroke:rgb(0,0,0);" />', + '<text x="29" y="17" text-anchor="middle" fill="rgb(0,0,0)" dominant-baseline="central">Start</text>', + '</svg>', + ].join(''), +}); + +const unsafePikchrRenderer: PikchrRenderer = () => ({ + kind: 'error', + message: 'Pikchr rendered unsafe SVG.', +}); diff --git a/apps/staged/src/lib/shared/markdown/renderMarkdown.ts b/apps/staged/src/lib/shared/markdown/renderMarkdown.ts new file mode 100644 index 000000000..d41e92344 --- /dev/null +++ b/apps/staged/src/lib/shared/markdown/renderMarkdown.ts @@ -0,0 +1,70 @@ +import { marked, Renderer, type Tokens } from 'marked'; + +import { sanitize } from '../sanitize'; +import { + renderMarkdownDiagramCodeBlock, + type MarkdownDiagramRenderingOptions, +} from './diagramRendering'; + +export type MarkdownRenderingOptions = MarkdownDiagramRenderingOptions; + +interface TrustedHtmlReplacement { + placeholder: string; + html: string; +} + +let fallbackPlaceholderSequence = 0; + +export function renderMarkdown(text: string, options: MarkdownRenderingOptions = {}): string { + const trustedHtml: TrustedHtmlReplacement[] = []; + const renderedMarkdown = sanitize( + marked.parse(text, { + breaks: true, + gfm: true, + renderer: createMarkdownRenderer(options, trustedHtml), + }) as string + ); + + return restoreTrustedHtml(renderedMarkdown, trustedHtml); +} + +function createMarkdownRenderer( + options: MarkdownRenderingOptions, + trustedHtml: TrustedHtmlReplacement[] +): Renderer { + const renderer = new Renderer(); + const renderCode = renderer.code.bind(renderer); + + renderer.code = (token: Tokens.Code) => { + const rendered = renderCode(token); + const diagram = renderMarkdownDiagramCodeBlock(token, rendered, options); + if (!diagram) return rendered; + if (!diagram.trustedHtml) return diagram.html; + + return stashTrustedHtml(diagram.html, trustedHtml); + }; + + return renderer; +} + +function stashTrustedHtml(html: string, trustedHtml: TrustedHtmlReplacement[]): string { + const placeholder = `STAGED_MARKDOWN_TRUSTED_DIAGRAM_${trustedHtml.length}_${createPlaceholderNonce()}`; + trustedHtml.push({ placeholder, html }); + return placeholder; +} + +function restoreTrustedHtml( + renderedMarkdown: string, + trustedHtml: TrustedHtmlReplacement[] +): string { + return trustedHtml.reduce((html, replacement) => { + return html.replaceAll(replacement.placeholder, replacement.html); + }, renderedMarkdown); +} + +function createPlaceholderNonce(): string { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) return randomUuid.replaceAll('-', '_'); + + return `${Date.now().toString(36)}_${fallbackPlaceholderSequence++}`; +} diff --git a/apps/staged/src/lib/theme.ts b/apps/staged/src/lib/theme.ts index 315e7a1c1..5a915d4f1 100644 --- a/apps/staged/src/lib/theme.ts +++ b/apps/staged/src/lib/theme.ts @@ -88,6 +88,11 @@ export interface Theme { selection: string; // Selected items background (theme-derived) }; + // Diagram previews + diagram: { + canvasBg: string; // Light canvas for rendered diagrams with default dark ink + }; + // Scrollbar scrollbar: { thumb: string; @@ -468,6 +473,10 @@ export function createAdaptiveTheme( selection: overlay(syntaxFg, isDark ? 0.08 : 0.1), }, + diagram: { + canvasBg: '#ffffff', + }, + scrollbar: { thumb: borderBase, thumbHover: mix(primaryBg, syntaxFg, 0.25), @@ -560,6 +569,8 @@ export function themeToVarMap(t: Theme): Record<string, string> { '--ui-warning-bg': t.ui.warningBg, '--ui-selection': t.ui.selection, + '--diagram-canvas-bg': t.diagram.canvasBg, + '--scrollbar-thumb': t.scrollbar.thumb, '--scrollbar-thumb-hover': t.scrollbar.thumbHover, '--scrollbar-thumb-transparent': t.scrollbar.thumbTransparent, diff --git a/apps/staged/src/types/sanitize-html.d.ts b/apps/staged/src/types/sanitize-html.d.ts index 932700317..8eac0a4f3 100644 --- a/apps/staged/src/types/sanitize-html.d.ts +++ b/apps/staged/src/types/sanitize-html.d.ts @@ -1,10 +1,18 @@ declare module 'sanitize-html' { type AllowedAttributes = Record<string, string[]>; + type AllowedStyles = Record<string, Record<string, RegExp[]>>; + type TagAttributes = Record<string, string>; + type TransformTag = ( + tagName: string, + attribs: TagAttributes + ) => { tagName: string; attribs: TagAttributes; text?: string }; interface SanitizeHtmlOptions { allowedTags?: string[]; allowedAttributes?: AllowedAttributes; + allowedStyles?: AllowedStyles; allowedSchemes?: string[]; + transformTags?: Record<string, TransformTag>; } interface SanitizeHtmlFn { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb82b2152..35b0fecb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: marked: specifier: ^17.0.1 version: 17.0.3 + pikchr-js: + specifier: 0.1.4 + version: 0.1.4 sanitize-html: specifier: ^2.17.0 version: 2.17.1 @@ -2550,6 +2553,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pikchr-js@0.1.4: + resolution: {integrity: sha512-ojG1pgxv5SzjGNSp/wDhkgzPo5jnK5/ycXDMh5dv5AW3vf0vhh4z60yk2ZBOh6VVx5R8ZqjO0/Ejk015gCBaPw==} + hasBin: true + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5518,6 +5525,8 @@ snapshots: picomatch@4.0.4: {} + pikchr-js@0.1.4: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8