From 8282b1d604a63d6d6026703ffe83fe333b190c4f Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 13:43:20 +0200 Subject: [PATCH] M8: chat content density (code blocks, scroll anchor, context menus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView and together make the conversation readable on a phone: 2.4 — fenced code blocks (```…```) now render in a horizontally- scrollable monospaced block inside the bubble. Collapsed to 240pt max height with Expand/Collapse + a copy button; long shell one-liners / JSON / stack traces stay one line each instead of soft-wrapping into unreadable 4-line columns. New `ChatContentFormatter.segments(for:)` splits the message body into alternating `.text` (routed through AttributedString markdown) and `.code` (routed to the new CodeBlockView). Deliberately simple parser — handles the common fence shape, leaves inline backticks to AttributedString, and falls back to plain text on unterminated fences so nothing is ever silently swallowed. 2.5 — tool-call cards were already collapsed-by-default via a chevron toggle. No structural change needed for M8; leaving the existing ToolCallCard in place. 2.6 — replace the manual `onChange → proxy.scrollTo("bottom")` pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's `.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll- pin fights the user's own scroll-up gesture less (the manual pattern yanked you back to the bottom if a chunk arrived mid-read). "New messages" pill for upward scroll-break deferred — needs a bit of ScrollPosition state we don't plumb yet. 2.7 — `.contextMenu` on every message bubble with Copy + Share (via ShareLink). User + assistant bubbles both. Code blocks get their own copy button in the header. Regenerate intentionally omitted — ACP has no native re-prompt primitive and implementing one would be non-trivial session-state surgery. Both schemes build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Scarf iOS/Chat/ChatContentFormatter.swift | 92 +++++++ scarf/Scarf iOS/Chat/ChatView.swift | 226 ++++++++++++------ 2 files changed, 251 insertions(+), 67 deletions(-) create mode 100644 scarf/Scarf iOS/Chat/ChatContentFormatter.swift diff --git a/scarf/Scarf iOS/Chat/ChatContentFormatter.swift b/scarf/Scarf iOS/Chat/ChatContentFormatter.swift new file mode 100644 index 0000000..304186e --- /dev/null +++ b/scarf/Scarf iOS/Chat/ChatContentFormatter.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Splits a chat message body into alternating text + fenced-code +/// segments so ChatView can render each part appropriately. Text +/// gets the existing AttributedString(markdown:) path; code gets a +/// horizontally-scrollable monospaced block (pass-1 UX: long lines +/// wrapped onto 4–5 visual rows each, which ate vertical space and +/// made code unreadable on an iPhone). +/// +/// Keeps the parser deliberately simple: we recognise the common +/// fenced form (```\n...\n``` and ```lang\n...\n```) and leave +/// everything else in the .text bucket. Inline `backticks` stay in +/// the text segment — AttributedString handles those fine. +enum ChatContentFormatter { + + enum Segment: Equatable { + case text(String) + case code(language: String?, body: String) + } + + /// Split the given message body into an ordered list of segments. + /// A body with no fenced code yields a single `.text` segment. + static func segments(for body: String) -> [Segment] { + // Fast path: no fences at all. + guard body.contains("```") else { return [.text(body)] } + + var result: [Segment] = [] + var pending = "" + var i = body.startIndex + + while i < body.endIndex { + // Try to match a fence opening at this position. + if body[i...].hasPrefix("```") { + // Flush the accumulated text. + if !pending.isEmpty { + result.append(.text(pending)) + pending = "" + } + + // Parse the optional language token up to the first newline. + let afterFence = body.index(i, offsetBy: 3) + var j = afterFence + while j < body.endIndex, body[j] != "\n" { + j = body.index(after: j) + } + let lang = String(body[afterFence.. Text { + if let attributed = try? AttributedString( + markdown: body, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) { + return Text(attributed) + } + return Text(body) + } +} + +/// Horizontally-scrollable fenced code block. ~240pt max height +/// collapsed (Expand button reveals full height). Monospaced +/// .footnote font keeps the bubble narrow enough to still show +/// adjacent text on the same screen. Language label is a tiny +/// header when present. +private struct CodeBlockView: View { + let language: String? + let code: String + @State private var expanded = false + + private let collapsedMaxHeight: CGFloat = 240 + + init(language: String?, body: String) { + self.language = language + self.code = body + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if let lang = language, !lang.isEmpty { + Text(lang.uppercased()) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Button(expanded ? "Collapse" : "Expand") { + withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + } + .font(.caption2) + .buttonStyle(.plain) + .foregroundStyle(.tint) + Button { + UIPasteboard.general.string = code + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + ScrollView(.horizontal, showsIndicators: true) { + Text(code) + .font(.footnote.monospaced()) + .textSelection(.enabled) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .fixedSize(horizontal: true, vertical: false) + } + .frame(maxHeight: expanded ? nil : collapsedMaxHeight) + .background(Color(.tertiarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } }