diff --git a/.gitignore b/.gitignore index 82606b160..b1d476204 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ Thumbs.db playwright-report/ test-results/ blob-report/ +.playwright-mcp/ # Node modules (pnpm workspace hoists to root) node_modules/ diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md index 5bec46770..bb9fe3762 100644 --- a/crates/sprout-acp/src/base_prompt.md +++ b/crates/sprout-acp/src/base_prompt.md @@ -23,6 +23,7 @@ Run `sprout --help` or `sprout --help` for full usage. ## Communication Patterns - Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery). +- Message content supports GitHub-flavored Markdown. Use fenced code blocks with a language tag (` ```python `, ` ```typescript `, etc.) for syntax-highlighted rendering on desktop and mobile. Omitting the language tag renders monochrome. - Use `sprout messages thread` when responding in-thread; post new messages for new topics. - No push notifications — poll with `sprout messages get --channel --since `. When `since` is set without `before`, results are oldest-first (chronological). diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3e5c29d5d..6b69ec297 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -170,7 +170,8 @@ async fn resolve_content_mentions( pub struct SendMessageParams { /// UUID of the channel to post to. pub channel_id: String, - /// Message body text. + /// Message body text. Supports GitHub-flavored Markdown including fenced code + /// blocks with syntax highlighting. pub content: String, /// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message). #[serde(default = "default_kind")] @@ -890,7 +891,9 @@ impl SproutMcpServer { /// Send a message to a Sprout channel. #[tool( name = "send_message", - description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \ + description = "Send a message to a Sprout channel. Content supports GitHub-flavored Markdown — \ +use fenced code blocks with a language tag for syntax-highlighted rendering. \ +Include `parent_event_id` to reply in a thread. \ Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \ For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \ Default kind is 9 (stream message)." diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 5b24d2f1a..fb856f044 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -74,6 +74,15 @@ Write commands are unaffected. `--format json` (default) returns full fields. Other kind values are rejected. Use `messages vote --event --direction up|down` to vote on forum posts. +## Message Formatting + +Message content is rendered as GitHub-flavored Markdown on both desktop and mobile. Key formatting: + +- **Fenced code blocks**: triple-backtick with a language tag for syntax highlighting (190+ languages supported). Omitting the language tag renders a styled monochrome block. +- **Inline code**: single backticks for inline monospace. +- **Mentions**: plain `@name` — do NOT bold or italicize (formatting prevents alert delivery). +- **Links, images, tables, blockquotes, headings**: standard GFM. + ## Mem Patch Workflow For safe concurrent writes, use hash-based conflict detection: diff --git a/desktop/src/features/messages/lib/codeBlockExtensions.ts b/desktop/src/features/messages/lib/codeBlockExtensions.ts index 73c468225..c3490eda4 100644 --- a/desktop/src/features/messages/lib/codeBlockExtensions.ts +++ b/desktop/src/features/messages/lib/codeBlockExtensions.ts @@ -23,7 +23,20 @@ export function handleCodeFenceEnter(ed: Editor): boolean | undefined { "", ); - if (FENCE_AT_START.test(textBefore)) return false; + const startMatch = textBefore.match(FENCE_AT_START); + if (startMatch) { + const { tr, schema } = ed.state; + const attrs = startMatch[1] ? { language: startMatch[1] } : {}; + tr.delete($cursor.start(), $cursor.pos); + tr.setBlockType( + tr.mapping.map($cursor.start()), + tr.mapping.map($cursor.start()), + schema.nodes.codeBlock, + attrs, + ); + ed.view.dispatch(tr); + return true; + } const m = textBefore.match(FENCE_AFTER_BREAK); if (!m) return undefined; diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 99a37101b..eafb1dce1 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -173,6 +173,43 @@ } } +/* ── Code block line numbers & diff ────────────────────────────────── */ +.code-block-lines { + counter-reset: code-line; +} + +.code-block-lines [data-line] { + display: block; + counter-increment: code-line; +} + +.code-block-lines [data-line]::before { + content: counter(code-line); + display: inline-block; + width: 2.5ch; + margin-right: 1.5ch; + text-align: right; + color: hsl(var(--muted-foreground) / 0.4); + user-select: none; + pointer-events: none; +} + +.code-line-diff-add { + background-color: hsl(120 40% 50% / 0.15); +} + +.code-line-diff-remove { + background-color: hsl(0 60% 50% / 0.15); +} + +.dark .code-line-diff-add { + background-color: hsl(120 40% 50% / 0.12); +} + +.dark .code-line-diff-remove { + background-color: hsl(0 60% 50% / 0.12); +} + @layer base { :root { /* Catppuccin Latte (mauve accent) */ diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 4ac8f36f8..0c3b49c22 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -9,6 +9,15 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { toast } from "sonner"; +import { + getSingletonHighlighter, + type HighlighterGeneric, + type BundledLanguage, + type BundledTheme, + type ThemedToken, +} from "shiki"; + +import { useTheme } from "@/shared/theme/ThemeProvider"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { isMessageLink, @@ -49,6 +58,59 @@ type ImetaEntry = { type ImetaLookup = Map; +let shikiHighlighter: HighlighterGeneric | null = + null; +let shikiInitPromise: Promise | null = null; +const loadedLangs = new Set(); +const loadedThemes = new Set(); +const tokenCache = new Map(); +const MAX_CACHE_ENTRIES = 100; +const MAX_LOADED_LANGUAGES = 30; +const MAX_HIGHLIGHT_LINES = 150; +const CODE_BLOCK_CLASS = + "code-block-lines block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground"; +const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/; +const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/; + +function ensureHighlighter(): Promise { + if (shikiHighlighter) return Promise.resolve(); + if (!shikiInitPromise) { + shikiInitPromise = getSingletonHighlighter({ + themes: [], + langs: [], + }).then((h) => { + shikiHighlighter = h; + }); + } + return shikiInitPromise; +} + +function extractLanguage(className?: string): string { + if (typeof className !== "string") return ""; + const match = className.match(/language-(\S+)/); + return match ? match[1] : ""; +} + +function stripDiffMarker(tokens: ThemedToken[], marker: RegExp): ThemedToken[] { + const last = tokens[tokens.length - 1]; + if (!last) return tokens; + const stripped = last.content.replace(marker, ""); + if (stripped === last.content) return tokens; + if (stripped === "") return tokens.slice(0, -1); + return [...tokens.slice(0, -1), { ...last, content: stripped }]; +} + +function useStableArray(arr: T[]): T[] { + const ref = React.useRef(arr); + if ( + arr.length !== ref.current.length || + arr.some((item, i) => item !== ref.current[i]) + ) { + ref.current = arr; + } + return ref.current; +} + /** * `urlTransform` for `` that preserves `sprout://message?…` * links. The default transform strips unknown schemes (returns `""`) before @@ -158,7 +220,13 @@ function getCodeBlockText(children: React.ReactNode) { return getReactNodeText(children).replace(/\n$/, ""); } -function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) { +function MarkdownCodeBlock({ + children, + language, +}: { + children?: React.ReactNode; + language?: string; +}) { const [isCopying, setIsCopying] = React.useState(false); const code = React.useMemo(() => getCodeBlockText(children), [children]); @@ -183,7 +251,12 @@ function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) { return (
-
+      
+        {language && (
+          
+ {language} +
+ )} {children}
@@ -261,6 +334,139 @@ function FileCard({ ); } +function SyntaxHighlightedCode({ + code, + language, + ...props +}: { + code: string; + language: string; +} & React.ComponentProps<"code">) { + const { themeName } = useTheme(); + const [loadedKey, setLoadedKey] = React.useState(0); + + React.useEffect(() => { + let cancelled = false; + async function loadAssets() { + try { + await ensureHighlighter(); + if (!shikiHighlighter || cancelled) return; + let loaded = false; + if (!loadedLangs.has(language)) { + if (loadedLangs.size >= MAX_LOADED_LANGUAGES) return; + try { + await shikiHighlighter.loadLanguage(language as BundledLanguage); + loadedLangs.add(language); + loaded = true; + } catch { + return; + } + } + if (!loadedThemes.has(themeName as string)) { + try { + await shikiHighlighter.loadTheme(themeName as BundledTheme); + loadedThemes.add(themeName as string); + loaded = true; + } catch { + return; + } + } + if (loaded && !cancelled) setLoadedKey((k) => k + 1); + } catch { + /* ignore */ + } + } + if (!loadedLangs.has(language) || !loadedThemes.has(themeName as string)) { + loadAssets(); + } + return () => { + cancelled = true; + }; + }, [language, themeName]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading + const tokens = React.useMemo(() => { + if ( + !shikiHighlighter || + !loadedLangs.has(language) || + !loadedThemes.has(themeName as string) + ) + return null; + if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null; + const cacheKey = `${language}:${themeName}:${code}`; + const cached = tokenCache.get(cacheKey); + if (cached) return cached; + try { + const result = shikiHighlighter.codeToTokens(code, { + lang: language as BundledLanguage, + theme: themeName as BundledTheme, + }); + if (tokenCache.size >= MAX_CACHE_ENTRIES) { + const firstKey = tokenCache.keys().next().value; + if (firstKey !== undefined) tokenCache.delete(firstKey); + } + tokenCache.set(cacheKey, result.tokens); + return result.tokens; + } catch { + return null; + } + }, [code, language, themeName, loadedKey]); + + const codeClassName = CODE_BLOCK_CLASS; + + if (!tokens) { + const lines = code.split("\n"); + return ( + + {lines.map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + + {line} + + ))} + + ); + } + + return ( + + {tokens.map((line, lineIdx) => { + const lineText = line.map((t) => t.content).join(""); + const isAdd = DIFF_ADD_RE.test(lineText); + const isRemove = DIFF_REMOVE_RE.test(lineText); + const diffClass = isAdd + ? "code-line-diff-add" + : isRemove + ? "code-line-diff-remove" + : undefined; + + const renderedTokens = + isAdd || isRemove + ? stripDiffMarker(line, isAdd ? DIFF_ADD_RE : DIFF_REMOVE_RE) + : line; + + return ( + + {renderedTokens.map((token, tokenIdx) => ( + + {token.content} + + ))} + + ); + })} + + ); +} function createMarkdownComponents( variant: MarkdownVariant, channels: Channel[], @@ -356,15 +562,23 @@ function createMarkdownComponents( typeof className === "string" && className.includes("language-"); if (isFencedCodeBlock || rawCode.endsWith("\n") || code.includes("\n")) { + const language = extractLanguage(className); + + if (language) { + return ( + + ); + } + + const lines = code.split("\n"); return ( - - {code} + + {lines.map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + + {line} + + ))} ); } @@ -517,12 +731,21 @@ function createMarkdownComponents( return

{children}

; }, - pre: ({ children }) => - interactive ? ( - {children} - ) : ( - {children} - ), + pre: ({ children }) => { + if (!interactive) return {children}; + let language = ""; + React.Children.forEach(children, (child) => { + if ( + React.isValidElement>(child) && + typeof child.props?.className === "string" + ) { + language = extractLanguage(child.props.className); + } + }); + return ( + {children} + ); + }, strong: ({ children }) => ( {children} ), @@ -665,7 +888,8 @@ function MarkdownInner({ : compact ? "compact" : "default"; - const { channels } = useChannelNavigation(); + const { channels: rawChannels } = useChannelNavigation(); + const channels = useStableArray(rawChannels); const { goChannel } = useAppNavigation(); const components = React.useMemo( diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index de6d6b7d9..9a6bae0d4 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -71,12 +71,12 @@ test("copy a rendered code block and paste it back as code", async ({ await page.keyboard.press("ControlOrMeta+V"); await page.getByTestId("send-message").click(); - const copiedCodeBlock = page.locator("pre", { hasText: code }); - await expect(copiedCodeBlock).toHaveCount(1); + const codeBlock = page.locator("[data-code-block]"); + await expect(codeBlock).toHaveCount(1); const copyButton = page.getByLabel("Copy code block"); await expect(copyButton).toHaveCSS("opacity", "0"); - await copiedCodeBlock.hover(); + await codeBlock.hover(); await expect(copyButton).toHaveCSS("opacity", "1"); await copyButton.click(); await expect @@ -87,7 +87,7 @@ test("copy a rendered code block and paste it back as code", async ({ await page.keyboard.press("ControlOrMeta+V"); await input.press("Enter"); - await expect(copiedCodeBlock).toHaveCount(2); + await expect(codeBlock).toHaveCount(2); }); test("pasting a long copied code block scrolls composer to cursor", async ({ @@ -115,7 +115,7 @@ test("pasting a long copied code block scrolls composer to cursor", async ({ await page.keyboard.press("ControlOrMeta+V"); await page.getByTestId("send-message").click(); - const copiedCodeBlock = page.locator("pre", { hasText: longCode }); + const copiedCodeBlock = page.locator("[data-code-block]"); await expect(copiedCodeBlock).toHaveCount(1); await copiedCodeBlock.hover(); await page.getByLabel("Copy code block").click(); @@ -134,6 +134,52 @@ test("pasting a long copied code block scrolls composer to cursor", async ({ .toBeLessThanOrEqual(1); }); +test("code block shows language label when language is specified", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + "```typescript\nconst x = 1;\n```", + ); + + await input.click(); + await page.keyboard.press("ControlOrMeta+V"); + await page.getByTestId("send-message").click(); + + const codeBlock = page.locator("[data-code-block]"); + await expect(codeBlock).toBeVisible(); + await expect(codeBlock.getByText("typescript")).toBeVisible(); +}); + +test("typing triple backticks and Enter creates a code block in composer", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.type("```"); + await page.keyboard.press("Enter"); + + // A
 code block should appear inside the ProseMirror editor
+  const editorPre = input.locator("pre");
+  await expect(editorPre).toBeVisible();
+
+  // The literal backticks should be consumed (not visible as text)
+  await expect(input).not.toContainText("```");
+});
+
 test("message input clears after send", async ({ page }) => {
   const message = `Clear after send ${Date.now()}`;
   const input = page.getByTestId("message-input");
diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart
index 24f57fe37..9f63bc2df 100644
--- a/mobile/lib/features/channels/compose_bar.dart
+++ b/mobile/lib/features/channels/compose_bar.dart
@@ -89,10 +89,12 @@ class ComposeBar extends HookConsumerWidget {
 
     // Typing indicator broadcast — throttled to one event per 3 seconds.
     final lastTypingSentMs = useRef(0);
+    final isModifyingText = useRef(false);
 
     // Detect @mention query and broadcast typing on text / selection change.
     useEffect(() {
       void listener() {
+        if (isModifyingText.value) return;
         final text = controller.text;
         final sel = controller.selection;
 
@@ -291,51 +293,27 @@ class ComposeBar extends HookConsumerWidget {
       final sel = controller.selection;
       if (!sel.isValid) return;
 
-      if (sel.isCollapsed) {
-        final offset = sel.baseOffset;
-        final updated =
-            '${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
-        controller.text = updated;
-        controller.selection = TextSelection.collapsed(
-          offset: offset + prefix.length,
-        );
-      } else {
-        final selected = text.substring(sel.start, sel.end);
-        final updated =
-            '${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
-        controller.text = updated;
-        controller.selection = TextSelection.collapsed(
-          offset: sel.start + prefix.length + selected.length + suffix.length,
-        );
-      }
-      focusNode.requestFocus();
-    }
-
-    void applyCodeBlock() {
-      final text = controller.text;
-      final sel = controller.selection;
-      if (!sel.isValid) return;
-
-      if (sel.isCollapsed) {
-        final offset = sel.baseOffset;
-        const open = '```\n';
-        const close = '\n```';
-        final updated =
-            '${text.substring(0, offset)}$open$close${text.substring(offset)}';
-        controller.text = updated;
-        controller.selection = TextSelection.collapsed(
-          offset: offset + open.length,
-        );
-      } else {
-        final selected = text.substring(sel.start, sel.end);
-        const open = '```\n';
-        const close = '\n```';
-        final updated =
-            '${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
-        controller.text = updated;
-        controller.selection = TextSelection.collapsed(
-          offset: sel.start + open.length + selected.length + close.length,
-        );
+      isModifyingText.value = true;
+      try {
+        if (sel.isCollapsed) {
+          final offset = sel.baseOffset;
+          final updated =
+              '${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
+          controller.text = updated;
+          controller.selection = TextSelection.collapsed(
+            offset: offset + prefix.length,
+          );
+        } else {
+          final selected = text.substring(sel.start, sel.end);
+          final updated =
+              '${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
+          controller.text = updated;
+          controller.selection = TextSelection.collapsed(
+            offset: sel.start + prefix.length + selected.length + suffix.length,
+          );
+        }
+      } finally {
+        isModifyingText.value = false;
       }
       focusNode.requestFocus();
     }
@@ -394,10 +372,7 @@ class ComposeBar extends HookConsumerWidget {
             children: [
               // Formatting toolbar (toggled via Aa button).
               if (showFormatting.value)
-                _FormattingToolbar(
-                  onFormat: applyFormat,
-                  onCodeBlock: applyCodeBlock,
-                ),
+                _FormattingToolbar(onFormat: applyFormat),
 
               if (hasAttachments || hasPendingUploads) ...[
                 _AttachmentStrip(
@@ -852,9 +827,8 @@ class _ChannelSuggestions extends StatelessWidget {
 
 class _FormattingToolbar extends StatelessWidget {
   final void Function(String prefix, [String? suffix]) onFormat;
-  final VoidCallback onCodeBlock;
 
-  const _FormattingToolbar({required this.onFormat, required this.onCodeBlock});
+  const _FormattingToolbar({required this.onFormat});
 
   @override
   Widget build(BuildContext context) {
@@ -885,7 +859,7 @@ class _FormattingToolbar extends StatelessWidget {
           _FormatButton(
             icon: LucideIcons.squareCode,
             tooltip: 'Code block',
-            onTap: onCodeBlock,
+            onTap: () => onFormat('```\n', '\n```'),
           ),
         ],
       ),
diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart
index 57d1cf2ac..a6156e1d7 100644
--- a/mobile/lib/features/channels/message_content.dart
+++ b/mobile/lib/features/channels/message_content.dart
@@ -1,11 +1,14 @@
 import 'dart:math' as math;
 
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:gpt_markdown/gpt_markdown.dart';
 import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
 import 'package:lucide_icons_flutter/lucide_icons.dart';
 import 'package:url_launcher/url_launcher.dart';
 
+import '../../shared/clipboard_utils.dart';
+import '../../shared/syntax_highlight.dart';
 import '../../shared/theme/theme.dart';
 import 'media_viewer_page.dart';
 import 'message_media.dart';
@@ -15,7 +18,7 @@ const _messageMediaMaxImageHeight = 240.0;
 
 /// Renders message content with markdown formatting, @mentions, #channel links,
 /// and media-aware markdown images/videos.
-class MessageContent extends StatelessWidget {
+class MessageContent extends HookWidget {
   final String content;
 
   /// Display names for mentioned pubkeys, extracted from event p-tags.
@@ -51,75 +54,80 @@ class MessageContent extends StatelessWidget {
         context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
     final imetaByUrl = parseImetaTags(tags);
 
-    // Convert autolinks and bare URLs to standard markdown links,
-    // but skip content inside backticks (inline code / fenced blocks).
-    final buffer = StringBuffer();
-    final parts = content.split('`');
-    for (var i = 0; i < parts.length; i++) {
-      if (i.isOdd) {
-        // Inside backticks — preserve as-is.
-        buffer.write('`${parts[i]}`');
-      } else {
-        // 1. Angle-bracket autolinks: 
-        var segment = parts[i].replaceAllMapped(
-          RegExp(r'<(https?://[^>]+)>'),
-          (m) => '[${m[1]}](${m[1]})',
-        );
-        // 2. Bare URLs not already inside markdown link/image syntax.
-        //    Negative lookbehind avoids matching URLs preceded by ]( or =
-        //    which are already part of markdown links or imeta tags.
-        segment = segment.replaceAllMapped(
-          RegExp(r'(?\]]+'),
-          (m) {
-            final url = m[0]!;
-            // Skip if this URL is already a markdown link label that equals
-            // the URL (produced by step 1 or authored as [url](url)).
-            final start = m.start;
-            if (start >= 1 && segment[start - 1] == '[') return url;
-            return '[$url]($url)';
-          },
-        );
-        buffer.write(segment);
+    final finalContent = useMemoized(() {
+      // Convert autolinks and bare URLs to standard markdown links,
+      // but skip content inside backticks (inline code / fenced blocks).
+      final buffer = StringBuffer();
+      final parts = content.split('`');
+      for (var i = 0; i < parts.length; i++) {
+        if (i.isOdd) {
+          // Inside backticks — preserve as-is.
+          buffer.write('`${parts[i]}`');
+        } else {
+          // 1. Angle-bracket autolinks: 
+          var segment = parts[i].replaceAllMapped(
+            RegExp(r'<(https?://[^>]+)>'),
+            (m) => '[${m[1]}](${m[1]})',
+          );
+          // 2. Bare URLs not already inside markdown link/image syntax.
+          //    Negative lookbehind avoids matching URLs preceded by ]( or =
+          //    which are already part of markdown links or imeta tags.
+          segment = segment.replaceAllMapped(
+            RegExp(r'(?\]]+'),
+            (m) {
+              final url = m[0]!;
+              // Skip if this URL is already a markdown link label that equals
+              // the URL (produced by step 1 or authored as [url](url)).
+              final start = m.start;
+              if (start >= 1 && segment[start - 1] == '[') return url;
+              return '[$url]($url)';
+            },
+          );
+          buffer.write(segment);
+        }
       }
-    }
-    final processed = buffer.toString();
-
-    // Replace spaces with non-breaking spaces inside known mention names
-    // so the gpt_markdown combined regex can match multi-word names
-    // even when caseSensitive is not preserved.
-    // Skip content inside backticks to avoid altering inline code.
-    final mentionParts = processed.split('`');
-    final mentionBuf = StringBuffer();
-    for (var i = 0; i < mentionParts.length; i++) {
-      if (i.isOdd) {
-        mentionBuf.write('`${mentionParts[i]}`');
-      } else {
-        var segment = mentionParts[i];
-        for (final name in mentionNames.values) {
-          if (name.contains(' ')) {
-            final nbspName = name.replaceAll(' ', '\u00A0');
-            segment = segment.replaceAllMapped(
-              RegExp('@${RegExp.escape(name)}', caseSensitive: false),
-              (m) => '@$nbspName',
-            );
+      final processed = buffer.toString();
+
+      // Replace spaces with non-breaking spaces inside known mention names
+      // so the gpt_markdown combined regex can match multi-word names
+      // even when caseSensitive is not preserved.
+      // Skip content inside backticks to avoid altering inline code.
+      final mentionParts = processed.split('`');
+      final mentionBuf = StringBuffer();
+      for (var i = 0; i < mentionParts.length; i++) {
+        if (i.isOdd) {
+          mentionBuf.write('`${mentionParts[i]}`');
+        } else {
+          var segment = mentionParts[i];
+          for (final name in mentionNames.values) {
+            if (name.contains(' ')) {
+              final nbspName = name.replaceAll(' ', '\u00A0');
+              segment = segment.replaceAllMapped(
+                RegExp('@${RegExp.escape(name)}', caseSensitive: false),
+                (m) => '@$nbspName',
+              );
+            }
           }
+          mentionBuf.write(segment);
         }
-        mentionBuf.write(segment);
       }
-    }
-    final mentionProcessed = mentionBuf.toString();
+      final mentionProcessed = mentionBuf.toString();
 
-    // Ensure channel links at the very start of content don't get
-    // swallowed by markdown processing.
-    var finalContent = mentionProcessed;
-    if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(finalContent)) {
-      finalContent = '\u200B$finalContent';
-    }
+      // Ensure channel links at the very start of content don't get
+      // swallowed by markdown processing.
+      var result = mentionProcessed;
+      if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
+        result = '\u200B$result';
+      }
+      return result;
+    }, [content, mentionNames]);
 
     return GptMarkdown(
       finalContent,
       style: style,
       followLinkColor: false,
+      codeBuilder: (context, name, code, closed) =>
+          _MessageCodeBlock(name: name, code: code),
       linkBuilder: (context, linkText, url, linkStyle) =>
           _buildLink(context, linkText, url, linkStyle, style),
       imageBuilder: (context, imageUrl) =>
@@ -443,6 +451,111 @@ class _MediaPreviewFallback extends StatelessWidget {
   }
 }
 
+class _MessageCodeBlock extends HookWidget {
+  final String name;
+  final String code;
+
+  const _MessageCodeBlock({required this.name, required this.code});
+
+  @override
+  Widget build(BuildContext context) {
+    final isCopied = useState(false);
+
+    Future handleCopy() async {
+      await copyToClipboard(context, code, message: 'Copied code to clipboard');
+      if (!context.mounted) return;
+      isCopied.value = true;
+      Future.delayed(const Duration(seconds: 2), () {
+        if (context.mounted) isCopied.value = false;
+      });
+    }
+
+    final codeBaseStyle = TextStyle(
+      fontFamily: 'GeistMono',
+      fontSize: 13,
+      height: 1.5,
+      color: context.colors.onSurface,
+    );
+    final isDark = context.theme.brightness == Brightness.dark;
+    final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
+    final codeSpans = useMemoized(
+      () => highlightCode(code, name, codeTheme, codeBaseStyle),
+      [code, name, isDark],
+    );
+    return Container(
+      margin: const EdgeInsets.only(top: Grid.half),
+      decoration: BoxDecoration(
+        color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
+        borderRadius: BorderRadius.circular(12),
+        border: Border.all(
+          color: context.colors.outline.withValues(alpha: 0.7),
+        ),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          if (name.isNotEmpty)
+            Padding(
+              padding: const EdgeInsets.only(
+                left: Grid.twelve,
+                top: Grid.half + Grid.quarter,
+              ),
+              child: Text(
+                name,
+                style: context.textTheme.labelSmall?.copyWith(
+                  color: context.colors.onSurfaceVariant,
+                ),
+              ),
+            ),
+          Stack(
+            children: [
+              Padding(
+                padding: EdgeInsets.fromLTRB(
+                  Grid.twelve,
+                  name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
+                  44,
+                  Grid.half + Grid.quarter,
+                ),
+                child: SingleChildScrollView(
+                  scrollDirection: Axis.horizontal,
+                  child: RichText(
+                    softWrap: false,
+                    text: TextSpan(style: codeBaseStyle, children: codeSpans),
+                  ),
+                ),
+              ),
+              Positioned(
+                top: 0,
+                right: Grid.quarter,
+                child: SizedBox(
+                  width: 28,
+                  height: 28,
+                  child: IconButton(
+                    onPressed: handleCopy,
+                    padding: EdgeInsets.zero,
+                    visualDensity: VisualDensity.compact,
+                    style: IconButton.styleFrom(
+                      tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+                    ),
+                    icon: Icon(
+                      isCopied.value ? LucideIcons.check : LucideIcons.copy,
+                      size: 14,
+                      color: isCopied.value
+                          ? context.colors.primary
+                          : context.colors.onSurfaceVariant,
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}
+
 class _MentionMd extends InlineMd {
   final Map mentionNames;
   late final RegExp _exp = _buildPrefixPattern(
diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart
index 27fe5cc27..da9818d50 100644
--- a/mobile/lib/features/profile/user_profile_sheet.dart
+++ b/mobile/lib/features/profile/user_profile_sheet.dart
@@ -1,9 +1,9 @@
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:lucide_icons_flutter/lucide_icons.dart';
 
+import '../../shared/clipboard_utils.dart';
 import '../../shared/relay/relay.dart';
 import '../../shared/theme/theme.dart';
 import '../../shared/utils/string_utils.dart';
@@ -173,13 +173,11 @@ class UserProfileSheet extends HookConsumerWidget {
                     color: context.colors.onSurfaceVariant,
                     fontFamily: 'monospace',
                   ),
-                  onTap: () {
-                    Clipboard.setData(ClipboardData(text: pubkey));
-                    ScaffoldMessenger.of(context).showSnackBar(
-                      const SnackBar(
-                        content: Text('Public key copied'),
-                        duration: Duration(seconds: 2),
-                      ),
+                  onTap: () async {
+                    await copyToClipboard(
+                      context,
+                      pubkey,
+                      message: 'Public key copied',
                     );
                   },
                 ),
diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart
index ea8bd6c8f..0ee093070 100644
--- a/mobile/lib/features/pulse/note_card.dart
+++ b/mobile/lib/features/pulse/note_card.dart
@@ -1,10 +1,10 @@
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:lucide_icons_flutter/lucide_icons.dart';
 import 'package:nostr/nostr.dart' as nostr;
 
+import '../../shared/clipboard_utils.dart';
 import '../../shared/theme/theme.dart';
 import '../channels/channel_detail_page.dart';
 import '../channels/channel_management_provider.dart';
@@ -182,10 +182,11 @@ class NoteCard extends HookConsumerWidget {
                     ),
                     _ActionButton(
                       icon: LucideIcons.share,
-                      onTap: () {
-                        Clipboard.setData(ClipboardData(text: _shareUri(note)));
-                        ScaffoldMessenger.of(context).showSnackBar(
-                          const SnackBar(content: Text('Copied note URI')),
+                      onTap: () async {
+                        await copyToClipboard(
+                          context,
+                          _shareUri(note),
+                          message: 'Copied note URI',
                         );
                       },
                     ),
diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart
index 4f0ed942a..cbc426e03 100644
--- a/mobile/lib/features/settings/settings_page.dart
+++ b/mobile/lib/features/settings/settings_page.dart
@@ -1,5 +1,4 @@
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -7,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr;
 import 'package:package_info_plus/package_info_plus.dart';
 
 import '../../shared/auth/auth.dart';
+import '../../shared/clipboard_utils.dart';
 import '../../shared/relay/relay.dart';
 import '../../shared/theme/theme.dart';
 import '../../shared/widgets/frosted_app_bar.dart';
@@ -76,13 +76,11 @@ class SettingsPage extends HookConsumerWidget {
                   ),
                   trailing: IconButton(
                     icon: const Icon(LucideIcons.copy, size: 16),
-                    onPressed: () {
-                      Clipboard.setData(ClipboardData(text: pubkey));
-                      ScaffoldMessenger.of(context).showSnackBar(
-                        const SnackBar(
-                          content: Text('Pubkey copied'),
-                          duration: Duration(seconds: 2),
-                        ),
+                    onPressed: () async {
+                      await copyToClipboard(
+                        context,
+                        pubkey,
+                        message: 'Pubkey copied',
                       );
                     },
                   ),
diff --git a/mobile/lib/shared/clipboard_utils.dart b/mobile/lib/shared/clipboard_utils.dart
new file mode 100644
index 000000000..d08ab9506
--- /dev/null
+++ b/mobile/lib/shared/clipboard_utils.dart
@@ -0,0 +1,14 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+Future copyToClipboard(
+  BuildContext context,
+  String text, {
+  String message = 'Copied to clipboard',
+}) async {
+  await Clipboard.setData(ClipboardData(text: text));
+  if (!context.mounted) return;
+  ScaffoldMessenger.of(context).showSnackBar(
+    SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
+  );
+}
diff --git a/mobile/lib/shared/syntax_highlight.dart b/mobile/lib/shared/syntax_highlight.dart
new file mode 100644
index 000000000..60e47cc16
--- /dev/null
+++ b/mobile/lib/shared/syntax_highlight.dart
@@ -0,0 +1,107 @@
+import 'package:flutter/material.dart';
+import 'package:highlight/highlight.dart' show highlight, Node;
+
+const highlightLightTheme = {
+  'keyword': TextStyle(color: Color(0xFFa626a4)),
+  'built_in': TextStyle(color: Color(0xFF0184bc)),
+  'type': TextStyle(color: Color(0xFF0184bc)),
+  'literal': TextStyle(color: Color(0xFF0184bc)),
+  'number': TextStyle(color: Color(0xFF986801)),
+  'string': TextStyle(color: Color(0xFF50a14f)),
+  'symbol': TextStyle(color: Color(0xFF50a14f)),
+  'comment': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
+  'doctag': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
+  'meta': TextStyle(color: Color(0xFF986801)),
+  'attr': TextStyle(color: Color(0xFF986801)),
+  'attribute': TextStyle(color: Color(0xFF986801)),
+  'title': TextStyle(color: Color(0xFF4078f2)),
+  'title.class_': TextStyle(color: Color(0xFFc18401)),
+  'title.function_': TextStyle(color: Color(0xFF4078f2)),
+  'name': TextStyle(color: Color(0xFFe45649)),
+  'tag': TextStyle(color: Color(0xFFe45649)),
+  'selector-tag': TextStyle(color: Color(0xFFe45649)),
+  'params': TextStyle(color: Color(0xFF383a42)),
+  'variable': TextStyle(color: Color(0xFFe45649)),
+  'subst': TextStyle(color: Color(0xFFe45649)),
+  'section': TextStyle(color: Color(0xFF4078f2)),
+  'bullet': TextStyle(color: Color(0xFF4078f2)),
+  'link': TextStyle(color: Color(0xFF4078f2)),
+  'addition': TextStyle(color: Color(0xFF50a14f)),
+  'deletion': TextStyle(color: Color(0xFFe45649)),
+};
+
+const highlightDarkTheme = {
+  'keyword': TextStyle(color: Color(0xFFc678dd)),
+  'built_in': TextStyle(color: Color(0xFF56b6c2)),
+  'type': TextStyle(color: Color(0xFF56b6c2)),
+  'literal': TextStyle(color: Color(0xFF56b6c2)),
+  'number': TextStyle(color: Color(0xFFd19a66)),
+  'string': TextStyle(color: Color(0xFF98c379)),
+  'symbol': TextStyle(color: Color(0xFF98c379)),
+  'comment': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
+  'doctag': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
+  'meta': TextStyle(color: Color(0xFFd19a66)),
+  'attr': TextStyle(color: Color(0xFFd19a66)),
+  'attribute': TextStyle(color: Color(0xFFd19a66)),
+  'title': TextStyle(color: Color(0xFF61afef)),
+  'title.class_': TextStyle(color: Color(0xFFe5c07b)),
+  'title.function_': TextStyle(color: Color(0xFF61afef)),
+  'name': TextStyle(color: Color(0xFFe06c75)),
+  'tag': TextStyle(color: Color(0xFFe06c75)),
+  'selector-tag': TextStyle(color: Color(0xFFe06c75)),
+  'params': TextStyle(color: Color(0xFFabb2bf)),
+  'variable': TextStyle(color: Color(0xFFe06c75)),
+  'subst': TextStyle(color: Color(0xFFe06c75)),
+  'section': TextStyle(color: Color(0xFF61afef)),
+  'bullet': TextStyle(color: Color(0xFF61afef)),
+  'link': TextStyle(color: Color(0xFF61afef)),
+  'addition': TextStyle(color: Color(0xFF98c379)),
+  'deletion': TextStyle(color: Color(0xFFe06c75)),
+};
+
+List highlightCode(
+  String code,
+  String language,
+  Map theme,
+  TextStyle baseStyle,
+) {
+  try {
+    if (language.isEmpty) return [TextSpan(text: code, style: baseStyle)];
+    final result = highlight.parse(code, language: language);
+    if (result.nodes == null) return [TextSpan(text: code, style: baseStyle)];
+    return buildSpans(result.nodes!, theme, baseStyle);
+  } catch (_) {
+    return [TextSpan(text: code, style: baseStyle)];
+  }
+}
+
+List buildSpans(
+  List nodes,
+  Map theme,
+  TextStyle baseStyle, {
+  int maxDepth = 10,
+}) {
+  final spans = [];
+  for (final node in nodes) {
+    if (maxDepth <= 0) {
+      if (node.value != null) {
+        spans.add(TextSpan(text: node.value, style: baseStyle));
+      }
+      continue;
+    }
+    if (node.children != null && node.children!.isNotEmpty) {
+      final childStyle = node.className != null
+          ? baseStyle.merge(theme[node.className])
+          : baseStyle;
+      spans.addAll(
+        buildSpans(node.children!, theme, childStyle, maxDepth: maxDepth - 1),
+      );
+    } else if (node.value != null) {
+      final style = node.className != null
+          ? baseStyle.merge(theme[node.className])
+          : baseStyle;
+      spans.add(TextSpan(text: node.value, style: style));
+    }
+  }
+  return spans;
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 8569de00e..b81f20083 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -472,6 +472,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.1.6"
+  highlight:
+    dependency: "direct main"
+    description:
+      name: highlight
+      sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.0"
   hooks:
     dependency: transitive
     description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 8c6b78193..e76010ed2 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -22,6 +22,7 @@ dependencies:
   pointycastle: ^4.0.0
   url_launcher: ^6.3.2
   gpt_markdown: ^1.1.6
+  highlight: ^0.7.0
   intl: ^0.20.2
   uuid: ^4.5.1
   image_picker: ^1.1.2
diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart
index 4f8a3a058..c2e12d9ab 100644
--- a/mobile/test/features/channels/message_content_test.dart
+++ b/mobile/test/features/channels/message_content_test.dart
@@ -254,6 +254,7 @@ void main() {
         );
 
         expect(_findRich('void main() {}'), findsWidgets);
+        expect(find.text('dart'), findsOneWidget);
       });
     });