feat: Add rich markdown rendering and skill editing (#11)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-08 23:30:44 -04:00
parent 790efb585b
commit 481b937c33
8 changed files with 375 additions and 25 deletions
@@ -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] = []
@@ -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..<dotIdx])
guard let num = Int(numStr), line[line.index(after: dotIdx)...].hasPrefix(" ") else { return nil }
let text = String(line[line.index(dotIdx, offsetBy: 2)...])
return .numberedItem(num, text)
}
private func isHorizontalRule(_ line: String) -> 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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
@@ -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
@@ -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
}
}
@@ -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)
}
}