From 481b937c336519e9b224bde5a2b95ccf400e0597 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Wed, 8 Apr 2026 23:30:44 -0400 Subject: [PATCH] feat: Add rich markdown rendering and skill editing (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a custom MarkdownContentView that renders markdown with visual styling — large headers, styled code blocks with language labels, bullet and numbered lists, blockquotes with colored borders, and horizontal rules. YAML frontmatter in skill files is hidden. Markdown rendering added to: - Memory view (MEMORY.md, USER.md) with live preview in editor - Skills view (.md files) with new edit/save capability - Session messages (assistant responses) - Dashboard text widgets Other changes: - Shared MarkdownRenderer utility for inline formatting - Split-pane editors (raw markdown left, live preview right) - saveSkillContent() in HermesFileService with path validation - Line breaks preserved in non-markdown content (Key: Value format) Closes #11 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Core/Services/HermesFileService.swift | 20 +- .../Core/Utilities/MarkdownContentView.swift | 261 ++++++++++++++++++ .../Core/Utilities/MarkdownRenderer.swift | 10 + .../Features/Memory/Views/MemoryView.swift | 22 +- .../Views/Widgets/TextWidgetView.swift | 6 +- .../Sessions/Views/SessionDetailView.swift | 8 +- .../Skills/ViewModels/SkillsViewModel.swift | 27 ++ .../Features/Skills/Views/SkillsView.swift | 46 ++- 8 files changed, 375 insertions(+), 25 deletions(-) create mode 100644 scarf/scarf/Core/Utilities/MarkdownContentView.swift create mode 100644 scarf/scarf/Core/Utilities/MarkdownRenderer.swift diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 3a271d2..8bf1f65 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -199,15 +199,23 @@ struct HermesFileService: Sendable { } func loadSkillContent(path: String) -> String { - // Validate path stays within the skills directory to prevent traversal - guard !path.contains(".."), - path.hasPrefix(HermesPaths.skillsDir) else { - print("[Scarf] Rejected skill path outside skills directory: \(path)") - return "" - } + guard isValidSkillPath(path) else { return "" } return readFile(path) ?? "" } + func saveSkillContent(path: String, content: String) { + guard isValidSkillPath(path) else { return } + writeFile(path, content: content) + } + + private func isValidSkillPath(_ path: String) -> Bool { + guard !path.contains(".."), path.hasPrefix(HermesPaths.skillsDir) else { + print("[Scarf] Rejected skill path outside skills directory: \(path)") + return false + } + return true + } + private func parseSkillRequiredConfig(_ path: String) -> [String] { guard let content = readFile(path) else { return [] } var result: [String] = [] diff --git a/scarf/scarf/Core/Utilities/MarkdownContentView.swift b/scarf/scarf/Core/Utilities/MarkdownContentView.swift new file mode 100644 index 0000000..e531d6a --- /dev/null +++ b/scarf/scarf/Core/Utilities/MarkdownContentView.swift @@ -0,0 +1,261 @@ +import SwiftUI + +struct MarkdownContentView: View { + let content: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + } + + @ViewBuilder + private func blockView(_ block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let text): + headingView(level: level, text: text) + case .paragraph(let text): + Text(MarkdownRenderer.inlineAttributedString(text)) + .textSelection(.enabled) + case .codeBlock(let code, let language): + codeBlockView(code: code, language: language) + case .bulletItem(let text, let indent): + bulletView(text: text, indent: indent) + case .numberedItem(let number, let text): + numberedView(number: number, text: text) + case .blockquote(let text): + blockquoteView(text: text) + case .horizontalRule: + Divider().padding(.vertical, 4) + case .blank: + Spacer().frame(height: 4) + } + } + + // MARK: - Block Views + + private func headingView(level: Int, text: String) -> some View { + let font: Font = switch level { + case 1: .title.bold() + case 2: .title2.bold() + case 3: .title3.bold() + case 4: .headline + default: .subheadline.bold() + } + return Text(MarkdownRenderer.inlineAttributedString(text)) + .font(font) + .textSelection(.enabled) + .padding(.top, level <= 2 ? 8 : 4) + } + + private func codeBlockView(code: String, language: String?) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let lang = language, !lang.isEmpty { + Text(lang) + .font(.caption2.bold()) + .foregroundStyle(.secondary) + } + Text(code) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .background(Color(.textBackgroundColor).opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } + + private func bulletView(text: String, indent: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\u{2022}") + .foregroundStyle(.secondary) + Text(MarkdownRenderer.inlineAttributedString(text)) + .textSelection(.enabled) + } + .padding(.leading, CGFloat(indent) * 16) + } + + private func numberedView(number: Int, text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\(number).") + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .trailing) + Text(MarkdownRenderer.inlineAttributedString(text)) + .textSelection(.enabled) + } + } + + private func blockquoteView(text: String) -> some View { + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1) + .fill(.blue.opacity(0.5)) + .frame(width: 3) + Text(MarkdownRenderer.inlineAttributedString(text)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.leading, 10) + } + .padding(.vertical, 2) + } + + // MARK: - Parser + + private func parseBlocks() -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + let lines = content.components(separatedBy: "\n") + var i = 0 + + // Skip YAML frontmatter (--- delimited block at start of file) + if i < lines.count && lines[i].trimmingCharacters(in: .whitespaces) == "---" { + i += 1 + while i < lines.count { + if lines[i].trimmingCharacters(in: .whitespaces) == "---" { + i += 1 + break + } + i += 1 + } + } + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Blank line + if trimmed.isEmpty { + if blocks.last != .blank { + blocks.append(.blank) + } + i += 1 + continue + } + + // Code block (fenced) + if trimmed.hasPrefix("```") { + let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var codeLines: [String] = [] + i += 1 + while i < lines.count { + if lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + i += 1 + break + } + codeLines.append(lines[i]) + i += 1 + } + blocks.append(.codeBlock(codeLines.joined(separator: "\n"), language: language.isEmpty ? nil : language)) + continue + } + + // Heading + if let heading = parseHeading(trimmed) { + blocks.append(heading) + i += 1 + continue + } + + // Horizontal rule + if isHorizontalRule(trimmed) { + blocks.append(.horizontalRule) + i += 1 + continue + } + + // Blockquote + if trimmed.hasPrefix("> ") { + var quoteLines: [String] = [] + while i < lines.count { + let l = lines[i].trimmingCharacters(in: .whitespaces) + if l.hasPrefix("> ") { + quoteLines.append(String(l.dropFirst(2))) + } else if l.hasPrefix(">") { + quoteLines.append(String(l.dropFirst(1))) + } else { + break + } + i += 1 + } + blocks.append(.blockquote(quoteLines.joined(separator: " "))) + continue + } + + // Bullet list + if let bullet = parseBullet(line) { + blocks.append(bullet) + i += 1 + continue + } + + // Numbered list + if let numbered = parseNumbered(trimmed) { + blocks.append(numbered) + i += 1 + continue + } + + // Paragraph — each line is its own paragraph to preserve line breaks + blocks.append(.paragraph(trimmed)) + i += 1 + } + + return blocks + } + + private func parseHeading(_ line: String) -> MarkdownBlock? { + let levels: [(prefix: String, level: Int)] = [ + ("##### ", 5), ("#### ", 4), ("### ", 3), ("## ", 2), ("# ", 1) + ] + for (prefix, level) in levels { + if line.hasPrefix(prefix) { + return .heading(level, String(line.dropFirst(prefix.count))) + } + } + return nil + } + + private func parseBullet(_ line: String) -> MarkdownBlock? { + let indent = line.prefix(while: { $0 == " " }).count / 2 + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") { + return .bulletItem(String(trimmed.dropFirst(2)), indent: indent) + } + if trimmed.hasPrefix("* ") { + return .bulletItem(String(trimmed.dropFirst(2)), indent: indent) + } + return nil + } + + private func parseNumbered(_ line: String) -> MarkdownBlock? { + guard let dotIdx = line.firstIndex(of: ".") else { return nil } + let numStr = String(line[line.startIndex.. Bool { + let stripped = line.replacingOccurrences(of: " ", with: "") + return (stripped.allSatisfy({ $0 == "-" }) && stripped.count >= 3) || + (stripped.allSatisfy({ $0 == "*" }) && stripped.count >= 3) || + (stripped.allSatisfy({ $0 == "_" }) && stripped.count >= 3) + } +} + +// MARK: - Block Model + +private enum MarkdownBlock: Equatable { + case heading(Int, String) + case paragraph(String) + case codeBlock(String, language: String?) + case bulletItem(String, indent: Int) + case numberedItem(Int, String) + case blockquote(String) + case horizontalRule + case blank +} diff --git a/scarf/scarf/Core/Utilities/MarkdownRenderer.swift b/scarf/scarf/Core/Utilities/MarkdownRenderer.swift new file mode 100644 index 0000000..7c990f2 --- /dev/null +++ b/scarf/scarf/Core/Utilities/MarkdownRenderer.swift @@ -0,0 +1,10 @@ +import Foundation + +enum MarkdownRenderer { + /// Inline-only rendering — bold, italic, code spans, links. Preserves whitespace/newlines. + static func inlineAttributedString(_ text: String) -> AttributedString { + (try? AttributedString(markdown: text, options: .init( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ))) ?? AttributedString(text) + } +} diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift index ac29e8a..76a1cbf 100644 --- a/scarf/scarf/Features/Memory/Views/MemoryView.swift +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -71,8 +71,7 @@ struct MemoryView: View { .foregroundStyle(.secondary) .padding() } else { - Text(markdownAttributed(content)) - .textSelection(.enabled) + MarkdownContentView(content: content) .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.5)) @@ -93,14 +92,17 @@ struct MemoryView: View { } .padding() Divider() - TextEditor(text: $viewModel.editText) - .font(.system(.body, design: .monospaced)) - .padding(8) + HSplitView { + TextEditor(text: $viewModel.editText) + .font(.system(.body, design: .monospaced)) + .padding(8) + ScrollView { + MarkdownContentView(content: viewModel.editText) + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } } - .frame(minWidth: 600, minHeight: 400) - } - - private func markdownAttributed(_ text: String) -> AttributedString { - (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text) + .frame(minWidth: 800, minHeight: 500) } } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift index fc7a8fb..6334cb3 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift @@ -9,10 +9,8 @@ struct TextWidgetView: View { .font(.caption) .foregroundStyle(.secondary) if let content = widget.content { - if widget.format == "markdown", - let attributed = try? AttributedString(markdown: content) { - Text(attributed) - .font(.callout) + if widget.format == "markdown" { + MarkdownContentView(content: content) } else { Text(content) .font(.callout) diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 5c22858..905b8eb 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -139,8 +139,12 @@ struct MessageBubble: View { .foregroundStyle(.orange) } if !message.content.isEmpty { - Text(message.content) - .textSelection(.enabled) + if message.isAssistant { + MarkdownContentView(content: message.content) + } else { + Text(message.content) + .textSelection(.enabled) + } } if !message.toolCalls.isEmpty { ForEach(message.toolCalls) { call in diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift index 6dc63b5..c52761f 100644 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -10,6 +10,8 @@ final class SkillsViewModel { var selectedFileName: String? var searchText = "" var missingConfig: [String] = [] + var isEditing = false + var editText = "" private var currentConfig = HermesConfig.empty var filteredCategories: [HermesSkillCategory] { @@ -61,4 +63,29 @@ final class SkillsViewModel { selectedFileName = file skillContent = fileService.loadSkillContent(path: skill.path + "/" + file) } + + var isMarkdownFile: Bool { + selectedFileName?.hasSuffix(".md") == true + } + + private var currentFilePath: String? { + guard let skill = selectedSkill, let file = selectedFileName else { return nil } + return skill.path + "/" + file + } + + func startEditing() { + editText = skillContent + isEditing = true + } + + func saveEdit() { + guard let path = currentFilePath else { return } + fileService.saveSkillContent(path: path, content: editText) + skillContent = editText + isEditing = false + } + + func cancelEditing() { + isEditing = false + } } diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 27cea3d..9dd2aeb 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -99,17 +99,57 @@ struct SkillsView: View { } if !viewModel.skillContent.isEmpty { Divider() - Text(viewModel.skillContent) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) + HStack { + Spacer() + Button("Edit") { viewModel.startEditing() } + .controlSize(.small) + } + if viewModel.isMarkdownFile { + MarkdownContentView(content: viewModel.skillContent) + } else { + Text(viewModel.skillContent) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } } } .padding() .frame(maxWidth: .infinity, alignment: .topLeading) } + .sheet(isPresented: $viewModel.isEditing) { + skillEditorSheet + } } else { ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list")) .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + private var skillEditorSheet: some View { + VStack(spacing: 0) { + HStack { + Text("Edit \(viewModel.selectedFileName ?? "File")") + .font(.headline) + Spacer() + Button("Cancel") { viewModel.cancelEditing() } + Button("Save") { viewModel.saveEdit() } + .buttonStyle(.borderedProminent) + } + .padding() + Divider() + HSplitView { + TextEditor(text: $viewModel.editText) + .font(.system(.body, design: .monospaced)) + .padding(8) + if viewModel.isMarkdownFile { + ScrollView { + MarkdownContentView(content: viewModel.editText) + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + } + .frame(minWidth: 800, minHeight: 500) + } }