mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -199,15 +199,23 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillContent(path: String) -> String {
|
func loadSkillContent(path: String) -> String {
|
||||||
// Validate path stays within the skills directory to prevent traversal
|
guard isValidSkillPath(path) else { return "" }
|
||||||
guard !path.contains(".."),
|
|
||||||
path.hasPrefix(HermesPaths.skillsDir) else {
|
|
||||||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return readFile(path) ?? ""
|
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] {
|
private func parseSkillRequiredConfig(_ path: String) -> [String] {
|
||||||
guard let content = readFile(path) else { return [] }
|
guard let content = readFile(path) else { return [] }
|
||||||
var result: [String] = []
|
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)
|
.foregroundStyle(.secondary)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
Text(markdownAttributed(content))
|
MarkdownContentView(content: content)
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.quaternary.opacity(0.5))
|
.background(.quaternary.opacity(0.5))
|
||||||
@@ -93,14 +92,17 @@ struct MemoryView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
Divider()
|
Divider()
|
||||||
TextEditor(text: $viewModel.editText)
|
HSplitView {
|
||||||
.font(.system(.body, design: .monospaced))
|
TextEditor(text: $viewModel.editText)
|
||||||
.padding(8)
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.padding(8)
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: viewModel.editText)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 800, minHeight: 500)
|
||||||
}
|
|
||||||
|
|
||||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
|
||||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ struct TextWidgetView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let content = widget.content {
|
if let content = widget.content {
|
||||||
if widget.format == "markdown",
|
if widget.format == "markdown" {
|
||||||
let attributed = try? AttributedString(markdown: content) {
|
MarkdownContentView(content: content)
|
||||||
Text(attributed)
|
|
||||||
.font(.callout)
|
|
||||||
} else {
|
} else {
|
||||||
Text(content)
|
Text(content)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
|||||||
@@ -139,8 +139,12 @@ struct MessageBubble: View {
|
|||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
Text(message.content)
|
if message.isAssistant {
|
||||||
.textSelection(.enabled)
|
MarkdownContentView(content: message.content)
|
||||||
|
} else {
|
||||||
|
Text(message.content)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !message.toolCalls.isEmpty {
|
if !message.toolCalls.isEmpty {
|
||||||
ForEach(message.toolCalls) { call in
|
ForEach(message.toolCalls) { call in
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final class SkillsViewModel {
|
|||||||
var selectedFileName: String?
|
var selectedFileName: String?
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
var missingConfig: [String] = []
|
var missingConfig: [String] = []
|
||||||
|
var isEditing = false
|
||||||
|
var editText = ""
|
||||||
private var currentConfig = HermesConfig.empty
|
private var currentConfig = HermesConfig.empty
|
||||||
|
|
||||||
var filteredCategories: [HermesSkillCategory] {
|
var filteredCategories: [HermesSkillCategory] {
|
||||||
@@ -61,4 +63,29 @@ final class SkillsViewModel {
|
|||||||
selectedFileName = file
|
selectedFileName = file
|
||||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + 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 {
|
if !viewModel.skillContent.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
Text(viewModel.skillContent)
|
HStack {
|
||||||
.font(.system(.body, design: .monospaced))
|
Spacer()
|
||||||
.textSelection(.enabled)
|
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()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.isEditing) {
|
||||||
|
skillEditorSheet
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user