eleven / messenger Public

Rich-text composer on iOS + Mac: ⌘B/⌘I style in the box; ***bold italic*** joins the grammar (#471)

* web+ios: rich-text composer — ⌘B/⌘I style in the box; ***bold italic*** joins the grammar

The iOS composer now edits styled text like Messages.app: ⌘B/⌘I (and the
selection callout's Format menu) bold/italic the text in the box, and the
draft serializes to the wire's marker grammar on send — the binding stays a
plain String, so send paths, slash commands and drafts are untouched.
Edit-loads parse markers back to glyphs. Underline is refused (no marker for
it); styled pastes flatten; caret offsets map to serialized positions for the
typeahead.

The grammar gains its one combined form, ***bold italic***, in both twins —
format.js and RichText.swift — with parity cases pinned on both sides, plus
the new RichTextEdit.swift runs↔markers converter in LChatCore (pure
Foundation, ready for the Mac's future native composer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: NSTextView-backed rich composer (⌘B/⌘I) + footer polish

MacComposerField replaces the SwiftUI TextField: rich text in the box
serialized to the marker grammar (the shared RichTextEdit bridge), Return/
Shift+Return split preserved, typeahead keys routed through handlers, paste
staging kept, and the typeahead gains real caret tracking (the old field
could only assume caret-at-end — the flagged "container work").

Footer polish from review: the composer band matches the chat stream's
background (was a lighter .bar material), more breathing room below the
input pill (Messages.app reference), and the + glyph renders white in dark
mode / dark grey in light via palette rendering instead of a knockout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: assemble the TextKit stack — NSTextView(textContainer: nil) has no text system

AppKit, unlike UIKit, does not build the text machinery when the designated
initializer gets a nil container: textContainer/layoutManager/textStorage are
all nil, typing is dead, and sizeThatFits' guards bail — which SwiftUI reads
as "no preferred size" and hands the field the whole pane (the giant-composer
bug). Build storage → layout manager → container by hand in the convenience
init, and make sizeThatFits answer every probe (zero/unspecified/infinite)
with a finite, nine-line-capped height plus the min/max sizes vertical growth
needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: composer polish + botless chats keep their client-answered commands

- The composer pill spans the full row (the representable reported its
  measured width at the max probe, so the HStack centred a short pill).
- The footer band is translucent again — ultraThinMaterial under a heavy
  windowBackgroundColor tint, so it reads as the feed's surface at rest while
  messages ghost through as they scroll beneath.
- The "/" typeahead in a chat with no AI agent now offers what the native
  client answers itself (/theme, /themes, /me) instead of nothing — mirroring
  the web's bot-flag rule (bot-only commands like /help stay gated). The old
  all-or-nothing hasBot gate was pre-existing, surfaced by testing the new
  field in a botless DM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: within-window frost for header + footer; locked spaces offer sign-in

Messages.app's actual translucency recipe: the transcript sits on the plain
window background and the header/footer are NSVisualEffectView within-window
blurs — a blur of a solid IS that solid, so the bands match the feed exactly
at rest and frost only when messages scroll beneath. FrostedBand wraps that
(plus a windowBackground tint to pull the material's luminance back to the
feed's hue); the composer footer swaps its tint hack for it, and plain chats'
headers move into a top safe-area inset so messages visibly pass under the
glass. Webview surfaces (takeover apps, companion splits) keep the stacked
header — an NSView fills its frame and would underlap an inset.

The locked-space pane ("can't be unlocked on this Mac") gains the primary
action its own copy asks for: Sign In with Eleven, right there — with busy/
failure states — instead of only offering removal while the sign-in hid in
Settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: frost is pure glass at full strength — tint and intensity were the enemies

Live-tuning found the two traps, both now documented in FrostedBand:
- Any tint mismatches the feed (windowBackgroundColor isn't what the pane
  sits on) — pure glass matches at rest by definition, so the band draws no
  colour of its own.
- alphaValue < 1 on an NSVisualEffectView silently disables its backdrop
  blur — every "lighter" frost was really blur-off with sharp text bleeding
  through a faded plate. Header and footer both run the full-strength
  .headerView glass.

Also tried and rejected on the way: heavier materials (drag in their own
grey), stacked layers (plate compounds to opaque), and a true CSS-style
backdrop-filter via public CALayer.backgroundFilters (compositor-ignored;
only the private CABackdropLayer samples the backdrop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bf66fdef1d5bc30c80163cf71a389ea164405bfb parent e067c4c
18 files changed, +1052 −126
  • changelog/2026-07-09-bold-italic-together.md +3 −0
  • ios/LChat/Views/ComposerField.swift +143 −9
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +18 −1
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/RichText.swift +6 −4
  • ios/LChatCore/Sources/LChatCore/RichTextEdit.swift +106 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +36 −5
  • ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift +109 −0
  • ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift +9 −0
  • ios/LChatMac/FrostedBand.swift +32 −0
  • ios/LChatMac/MacComposerField.swift +403 −0
  • ios/LChatMac/NativeChatView.swift +23 −6
  • ios/LChatMac/NativeFeedView.swift +106 −94
  • ios/LChatMac/RootView.swift +15 −0
  • test/format.test.mjs +16 −0
  • web/static/apps.js +7 −0
  • web/static/apps/chat.js +7 −0
  • web/static/format.js +9 −7
diff --git a/changelog/2026-07-09-bold-italic-together.md b/changelog/2026-07-09-bold-italic-together.md
new file mode 100644
index 0000000..a457858
--- /dev/null
+++ b/changelog/2026-07-09-bold-italic-together.md
@@ -0,0 +1,3 @@
+🤖 Bold and italic now combine: wrap text in ***three asterisks*** to make it
+both at once, anywhere formatting works — alongside the **bold**, *italic*,
+~strikethrough~ and `code` you already had.
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 400a079..3027df1 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -1,5 +1,6 @@
import SwiftUI
import UIKit
+import LChatCore
// The composer's text input. We drop to a UITextView (via a representable)
// instead of SwiftUI's TextField because we need to intercept Return *before* it
@@ -11,11 +12,21 @@ import UIKit
// `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return
// sends and Shift+Return inserts a newline (matching the web composer on
// desktop); on touch, Return always inserts a newline and the send button sends.
+//
+// Rich text: the field edits *styled* text (⌘B/⌘I and the selection callout's
+// Format menu — UIKit wires both once allowsEditingTextAttributes is on), but
+// the `text` binding stays a plain String holding the wire's marker form
+// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so send paths, slash
+// commands and drafts are untouched. Display ↔ markers converts at the edges:
+// serialize on every change, parse on external sets (an edit-load shows glyphs,
+// not asterisks). Underline is refused — the grammar has no marker for it, and
+// silently dropping it on send would be worse than not offering it.
struct ComposerField: UIViewRepresentable {
@Binding var text: String
@Binding var focused: Bool
// Caret position as a UTF-16 offset (UIKit's native unit) — drives the
- // typeahead, which needs to know which token the caret sits in.
+ // typeahead, which needs to know which token the caret sits in. Offsets are
+ // into the *serialized* text (the binding), not the styled display text.
var caret: Binding<Int>? = nil
var placeholder: String = ""
var sendOnReturn: Bool
@@ -36,6 +47,9 @@ struct ComposerField: UIViewRepresentable {
// Live-track the system Text Size (Dynamic Type) — preferredFont only
// sizes at creation without this.
tv.adjustsFontForContentSizeCategory = true
+ // ⌘B/⌘I toggle traits on the selection (or the typing attributes at a
+ // bare caret) — UIKit provides the key commands and the Format menu.
+ tv.allowsEditingTextAttributes = true
tv.backgroundColor = .clear
tv.textContainerInset = .zero
tv.textContainer.lineFragmentPadding = 0
@@ -45,7 +59,14 @@ struct ComposerField: UIViewRepresentable {
func updateUIView(_ tv: GrowingTextView, context: Context) {
context.coordinator.parent = self
- if tv.text != text { tv.text = text }
+ // Compare serialized forms: user edits round-trip exactly (didChange set
+ // the binding from this very serialization), so only *external* sets —
+ // an edit-load, a clear after send — reparse the view. Canonicalize the
+ // target first so a draft arriving as `_i_` (which serializes back as
+ // `*i*`) doesn't re-set the view on every update.
+ if tv.serializedText() != serializeRuns(parseRuns(text)) {
+ tv.setMarkerText(text)
+ }
tv.placeholderLabel.text = placeholder
tv.placeholderLabel.isHidden = !text.isEmpty
// Drive first responder from the focus binding — async so we never mutate
@@ -76,13 +97,15 @@ struct ComposerField: UIViewRepresentable {
init(_ parent: ComposerField) { self.parent = parent }
func textViewDidChange(_ tv: UITextView) {
- parent.text = tv.text
- parent.caret?.wrappedValue = tv.selectedRange.location
- (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty
+ guard let tv = tv as? GrowingTextView else { return }
+ parent.text = tv.serializedText()
+ parent.caret?.wrappedValue = tv.serializedCaret()
+ tv.placeholderLabel.isHidden = !tv.text.isEmpty
}
func textViewDidChangeSelection(_ tv: UITextView) {
- parent.caret?.wrappedValue = tv.selectedRange.location
+ guard let tv = tv as? GrowingTextView else { return }
+ parent.caret?.wrappedValue = tv.serializedCaret()
}
func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange,
@@ -104,9 +127,9 @@ struct ComposerField: UIViewRepresentable {
}
}
-// A UITextView that reports a placeholder, caps its growth at six lines, and
-// tracks whether Shift is held so Shift+Return can mean "newline" while a bare
-// Return sends.
+// A UITextView that reports a placeholder, caps its growth at six lines, tracks
+// whether Shift is held so Shift+Return can mean "newline" while a bare Return
+// sends, and converts its styled contents to/from the wire's marker grammar.
final class GrowingTextView: UITextView {
let placeholderLabel = UILabel()
private(set) var shiftHeld = false
@@ -116,9 +139,18 @@ final class GrowingTextView: UITextView {
// Hardware-keyboard ↑ with an empty draft (the desktop edit convention).
var onEditLast: (() -> Void)?
+ // The unstyled body font — the base every run's traits derive from. Never
+ // read `font` for this: after styled content lands, `font` reports the run
+ // at index 0, and deriving from a bold base would bold everything.
+ private var baseFont: UIFont { .preferredFont(forTextStyle: .body) }
+ private var baseColor: UIColor { textColor ?? .label }
+
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)), onPasteImage != nil,
UIPasteboard.general.hasImages { return true }
+ // No underline: the marker grammar can't carry it, and applying a style
+ // that silently vanishes on send would be a lie. (Bold/italic stay.)
+ if action == #selector(toggleUnderline(_:)) { return false }
return super.canPerformAction(action, withSender: sender)
}
@@ -130,9 +162,111 @@ final class GrowingTextView: UITextView {
}
if !pb.hasStrings { return } // image-only paste fully consumed
}
+ // Flatten styled pastes: allowsEditingTextAttributes would otherwise keep
+ // a web page's fonts/colors, which the marker grammar can't represent.
+ // insertText adopts the caret's typing attributes — pasting into a bold
+ // stretch stays bold, like Messages.
+ if let s = pb.string {
+ insertText(s)
+ delegate?.textViewDidChange?(self) // programmatic edits don't always notify
+ return
+ }
super.paste(sender)
}
+ // toggleBoldface/toggleItalics change attributes without changing text, so
+ // textViewDidChange never fires — but the binding must update *now*: a send
+ // right after ⌘B would otherwise serialize the pre-toggle draft.
+ override func toggleBoldface(_ sender: Any?) {
+ super.toggleBoldface(sender)
+ delegate?.textViewDidChange?(self)
+ }
+ override func toggleItalics(_ sender: Any?) {
+ super.toggleItalics(sender)
+ delegate?.textViewDidChange?(self)
+ }
+
+ // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ----
+
+ // The style of the character at a UTF-16 index, as an empty-text run.
+ private func styleAt(_ index: Int) -> RichRun {
+ let attrs = attributedText.attributes(at: index, effectiveRange: nil)
+ let font = attrs[.font] as? UIFont
+ let traits = font?.fontDescriptor.symbolicTraits ?? []
+ let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
+ return RichRun("", bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic),
+ strike: strike, code: traits.contains(.traitMonoSpace))
+ }
+
+ private func runs(of a: NSAttributedString) -> [RichRun] {
+ var runs: [RichRun] = []
+ let str = a.string as NSString
+ a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in
+ let font = attrs[.font] as? UIFont
+ let traits = font?.fontDescriptor.symbolicTraits ?? []
+ let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
+ let run = RichRun(str.substring(with: range),
+ bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic),
+ strike: strike, code: traits.contains(.traitMonoSpace))
+ // Coalesce: UIKit splits runs on attributes (color, kerning…) that
+ // don't matter to the grammar.
+ if let last = runs.last, last.sameStyle(as: run) {
+ runs[runs.count - 1].text += run.text
+ } else {
+ runs.append(run)
+ }
+ }
+ return runs
+ }
+
+ /// The field's contents in wire form — what the `text` binding holds.
+ func serializedText() -> String { serializeRuns(runs(of: attributedText)) }
+
+ /// Replace the contents from wire form (an edit-load / external set): the
+ /// draft shows glyphs, not asterisks.
+ func setMarkerText(_ text: String) {
+ let out = NSMutableAttributedString()
+ for run in parseRuns(text) {
+ var traits = baseFont.fontDescriptor.symbolicTraits
+ if run.bold { traits.insert(.traitBold) }
+ if run.italic { traits.insert(.traitItalic) }
+ var font = baseFont
+ if run.code {
+ font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
+ if run.bold || run.italic, let d = font.fontDescriptor.withSymbolicTraits(traits) {
+ font = UIFont(descriptor: d, size: 0)
+ }
+ } else if run.bold || run.italic, let d = baseFont.fontDescriptor.withSymbolicTraits(traits) {
+ font = UIFont(descriptor: d, size: 0)
+ }
+ var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor]
+ if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
+ out.append(NSAttributedString(string: run.text, attributes: attrs))
+ }
+ attributedText = out
+ // A cleared field must not inherit the last run's style into the next
+ // message — reset the caret style to plain.
+ if text.isEmpty { typingAttributes = [.font: baseFont, .foregroundColor: baseColor] }
+ }
+
+ /// The caret as an offset into the *serialized* text. Serializing the prefix
+ /// before the caret counts every marker the caret sits after; when the caret
+ /// is strictly inside a styled run, that prefix closed the run's markers
+ /// behind it — characters the true position is before, so back them off.
+ func serializedCaret() -> Int {
+ let loc = selectedRange.location
+ guard loc > 0, loc <= attributedText.length else { return 0 }
+ let prefix = attributedText.attributedSubstring(from: NSRange(location: 0, length: loc))
+ var n = (serializeRuns(runs(of: prefix)) as NSString).length
+ if loc < attributedText.length {
+ let before = styleAt(loc - 1)
+ if before.sameStyle(as: styleAt(loc)), let marker = before.marker {
+ n -= (marker as NSString).length
+ }
+ }
+ return n
+ }
+
private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight }
var capHeight: CGFloat { ceil(lineHeight * 6) }
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
index 1d6e462..cff839d 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
@@ -89,7 +89,15 @@ public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry],
switch token.kind {
case .command:
let q = token.query.lowercased()
- return (appCommands + (hasBot ? botCommands : []))
+ // The web's rule (shell.js typeaheadItems): bot-answered commands show
+ // only where a bot can answer them; client-answered ones show in every
+ // thread — a botless DM still offers what the client itself runs. The
+ // native split differs from the web's own: here the CLIENT answers
+ // theming and /me (SlashCommands.swift) while /help falls through to a
+ // bot — the web's mirror image — so each side ungates exactly what it
+ // can answer itself.
+ let floor = botCommands.filter { hasBot || clientAnswered.contains($0.name) } + [meCommand]
+ return (appCommands + floor)
.filter { $0.name.dropFirst().lowercased().hasPrefix(q) }
.prefix(8)
.map { .command(name: $0.name, desc: $0.desc) }
@@ -118,6 +126,15 @@ public let botCommands: [(name: String, desc: String)] = [
("/profile", "edit your screen name & photo"),
]
+// The subset of the floor the NATIVE client answers itself (SlashCommands.swift
+// routes them locally) — offered in every thread, bot or no bot. /help, /dark,
+// /light and /profile still fall through to a bot, so they stay gated.
+let clientAnswered: Set<String> = ["/theme", "/themes"]
+
+// /me is handled by the composer itself (SlashCommand.parseMe) — listed so the
+// "/" typeahead surfaces it everywhere, like the web's commandCatalog does.
+let meCommand = (name: "/me", desc: "post an action line, e.g. /me waves")
+
// ---- emoji (emoji.js) -----------------------------------------------------
/// emoji.js emojiMatches: prefix hits first, then substring; shorter names win
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 9ee2d3a..57f804b 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -518,6 +518,10 @@ public struct MessageBubble: View {
var a = AttributedString(s)
a.font = .system(size: fontSize).italic()
out += a
+ case .boldItalic(let s):
+ var a = AttributedString(s)
+ a.font = .system(size: fontSize, weight: .bold).italic()
+ out += a
case .strike(let s):
var a = AttributedString(s)
a.strikethroughStyle = .single
diff --git a/ios/LChatCore/Sources/LChatCore/RichText.swift b/ios/LChatCore/Sources/LChatCore/RichText.swift
index 50567fb..9718ac8 100644
--- a/ios/LChatCore/Sources/LChatCore/RichText.swift
+++ b/ios/LChatCore/Sources/LChatCore/RichText.swift
@@ -11,6 +11,7 @@ public enum RichToken: Equatable {
case link(String, URL) // display text + validated http(s) href
case bold(String)
case italic(String)
+ case boldItalic(String) // ***both*** — the one combined mark
case strike(String)
case code(String)
}
@@ -22,7 +23,7 @@ private let urlRE = try! NSRegularExpression(
options: [.caseInsensitive])
private let trailingRE = try! NSRegularExpression(pattern: "[.,!?;:'\")\\]}]+$")
private let formatRE = try! NSRegularExpression(
- pattern: "`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|\\*([^*\\n]+)\\*|(?<![\\w])_([^_\\n]+)_(?![\\w])|~([^~\\n]+)~")
+ pattern: "`([^`\\n]+)`|\\*\\*\\*([^*\\n]+)\\*\\*\\*|\\*\\*([^*\\n]+)\\*\\*|\\*([^*\\n]+)\\*|(?<![\\w])_([^_\\n]+)_(?![\\w])|~([^~\\n]+)~")
// format.js BARE_TLDS: scheme-less "host.tld" links only for real TLDs, so
// node.js / index.html / main.go stay plain text.
@@ -83,7 +84,8 @@ func tokenizeText(_ text: String) -> [(text: String, href: URL?)] {
return tokens
}
-// format.js formatSegment: inline `code` / **bold** / *italic* / _italic_ / ~strike~.
+// format.js formatSegment: inline `code` / ***bold italic*** / **bold** /
+// *italic* / _italic_ / ~strike~.
private func formatSegment(_ text: String, into out: inout [RichToken]) {
let str = ns(text)
var last = 0
@@ -92,8 +94,8 @@ private func formatSegment(_ text: String, into out: inout [RichToken]) {
out.append(.text(str.substring(with: NSRange(location: last, length: m.range.location - last))))
}
let makers: [(Int, (String) -> RichToken)] = [
- (1, RichToken.code), (2, RichToken.bold), (3, RichToken.italic),
- (4, RichToken.italic), (5, RichToken.strike),
+ (1, RichToken.code), (2, RichToken.boldItalic), (3, RichToken.bold),
+ (4, RichToken.italic), (5, RichToken.italic), (6, RichToken.strike),
]
for (group, make) in makers {
let r = m.range(at: group)
diff --git a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift
new file mode 100644
index 0000000..69f3e83
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+// The bridge between a rich-text composer and the wire's marker grammar
+// (format.js / RichText.swift): a draft is modelled as a flat list of styled
+// runs, serialized to markers on send and parsed back for edit-loads. Pure
+// Foundation — the UIKit NSAttributedString adapter lives with ComposerField;
+// this half is shared so the Mac's native composer can reuse it.
+//
+// The grammar is single-level (no nesting) with one combined form:
+// `code` ***bold italic*** **bold** *italic* ~strike~
+// so a run can't carry every style combination. serializeRuns resolves the
+// inexpressible ones by priority: code wins outright, then bold/italic
+// (together as ***), and strike only stands alone — a strike combined with
+// bold/italic keeps the bold/italic and drops the strike.
+
+/// A stretch of draft text with one uniform style.
+public struct RichRun: Equatable {
+ public var text: String
+ public var bold: Bool
+ public var italic: Bool
+ public var strike: Bool
+ public var code: Bool
+
+ public init(_ text: String, bold: Bool = false, italic: Bool = false,
+ strike: Bool = false, code: Bool = false) {
+ self.text = text
+ self.bold = bold
+ self.italic = italic
+ self.strike = strike
+ self.code = code
+ }
+
+ public var isPlain: Bool { !bold && !italic && !strike && !code }
+
+ public func sameStyle(as other: RichRun) -> Bool {
+ bold == other.bold && italic == other.italic && strike == other.strike && code == other.code
+ }
+
+ /// The marker pair for this style, resolved by the priority above; nil for
+ /// plain runs. Italic serializes as `*`, never `_` — the underscore form
+ /// needs word boundaries (file_names), the asterisk works mid-word too.
+ /// Public for the composer's caret math (mapping display offsets to
+ /// serialized offsets needs marker lengths).
+ public var marker: String? {
+ if code { return "`" }
+ if bold && italic { return "***" }
+ if bold { return "**" }
+ if italic { return "*" }
+ if strike { return "~" }
+ return nil
+ }
+}
+
+/// Serialize composer runs to marker text. Markers can't span newlines (the
+/// grammar is per-line), so a styled run is split and each line wrapped on its
+/// own; whitespace-only stretches stay unwrapped (`** **` is noise, and the
+/// style is invisible on spaces anyway). A styled run whose text contains its
+/// own marker char will mis-parse on the receiver — the same edge web users
+/// typing markers by hand have always had; there is no escaping in the grammar.
+public func serializeRuns(_ runs: [RichRun]) -> String {
+ var out = ""
+ for run in runs {
+ guard !run.text.isEmpty else { continue }
+ guard let marker = run.marker else {
+ out += run.text
+ continue
+ }
+ // Split keeping empty segments so "a\n\nb" keeps both newlines.
+ let lines = run.text.split(separator: "\n", omittingEmptySubsequences: false)
+ out += lines.map { line in
+ let s = String(line)
+ if s.isEmpty || s.allSatisfy({ $0 == " " || $0 == "\t" }) { return s }
+ return marker + s + marker
+ }.joined(separator: "\n")
+ }
+ return out
+}
+
+/// Parse marker text back into runs (the edit-load direction), via the same
+/// richTokens the feed renders with — so the composer shows exactly what the
+/// bubble showed. Links flatten to plain text (the composer styles nothing
+/// about them; their text round-trips verbatim). Adjacent same-style runs
+/// coalesce so the output is canonical.
+public func parseRuns(_ text: String) -> [RichRun] {
+ var runs: [RichRun] = []
+ func push(_ run: RichRun) {
+ guard !run.text.isEmpty else { return }
+ if let last = runs.last, last.sameStyle(as: run) {
+ runs[runs.count - 1].text += run.text
+ } else {
+ runs.append(run)
+ }
+ }
+ for token in richTokens(text) {
+ switch token {
+ case .text(let s): push(RichRun(s))
+ case .link(let s, _): push(RichRun(s))
+ case .bold(let s): push(RichRun(s, bold: true))
+ case .italic(let s): push(RichRun(s, italic: true))
+ case .boldItalic(let s): push(RichRun(s, bold: true, italic: true))
+ case .strike(let s): push(RichRun(s, strike: true))
+ case .code(let s): push(RichRun(s, code: true))
+ }
+ }
+ return runs
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
index 3a80a3f..1ba233f 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
@@ -49,6 +49,29 @@ final class ComposerTypeaheadTests: XCTestCase {
"Swift botCommands has drifted from web/static/bot-commands.js")
}
+ // ---- command availability without a bot --------------------------------
+
+ private func commandNames(hasBot: Bool) -> [String] {
+ typeaheadItems(ComposerToken(kind: .command, query: "", start: 0),
+ roster: [], myUserId: "me", hasBot: hasBot)
+ .compactMap { if case .command(let name, _) = $0 { return name } else { return nil } }
+ }
+
+ func testBotlessChatStillOffersClientAnsweredCommands() {
+ // A DM with no AI agent must still offer what the native client answers
+ // itself (SlashCommands.swift): theming and /me — and nothing that only
+ // a bot could answer (/help would post into the void).
+ XCTAssertEqual(commandNames(hasBot: false), ["/theme", "/themes", "/me"])
+ }
+
+ func testBotfulChatOffersTheFullFloor() {
+ let names = commandNames(hasBot: true)
+ for expected in botCommands.map(\.name) {
+ XCTAssertTrue(names.contains(expected), "\(expected) missing with a bot present")
+ }
+ XCTAssertTrue(names.contains("/me"))
+ }
+
// ---- composerToken (ui.js) parity -------------------------------------
func testCommandToken() {
@@ -112,10 +135,17 @@ final class ComposerTypeaheadTests: XCTestCase {
}
func testCommandCandidatesAreBotGated() {
- let tok = ComposerToken(kind: .command, query: "the", start: 0)
- XCTAssertEqual(typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: true).map(\.insert),
+ // Theming is client-answered (SlashCommands.swift), so it shows with or
+ // without a bot; a bot-only command like /help gates on hasBot.
+ let the = ComposerToken(kind: .command, query: "the", start: 0)
+ XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: true).map(\.insert),
+ ["/theme ", "/themes "])
+ XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: false).map(\.insert),
["/theme ", "/themes "])
- XCTAssertTrue(typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: false).isEmpty)
+ let he = ComposerToken(kind: .command, query: "he", start: 0)
+ XCTAssertEqual(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: true).map(\.insert),
+ ["/help "])
+ XCTAssertTrue(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: false).isEmpty)
}
// App-local verbs (web commandCatalog(threadId)): they show in the owning
@@ -125,11 +155,12 @@ final class ComposerTypeaheadTests: XCTestCase {
let bare = ComposerToken(kind: .command, query: "", start: 0)
XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: false,
appCommands: add).map(\.insert),
- ["/add "], "no bot → the local verb still shows, alone")
+ ["/add ", "/theme ", "/themes ", "/me "],
+ "no bot → the local verb leads; client-answered floor follows")
XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: true,
appCommands: add).map(\.insert).first,
"/add ", "with a bot the local verb leads the catalog")
- let miss = ComposerToken(kind: .command, query: "the", start: 0)
+ let miss = ComposerToken(kind: .command, query: "he", start: 0)
XCTAssertTrue(typeaheadItems(miss, roster: roster, myUserId: "u2", hasBot: false,
appCommands: add).isEmpty,
"prefix filtering applies to app commands too")
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift
new file mode 100644
index 0000000..6672075
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift
@@ -0,0 +1,109 @@
+import XCTest
+@testable import LChatCore
+
+// The composer's runs ↔ markers bridge (RichTextEdit.swift). The hard
+// invariant is the round trip: parse(serialize(runs)) == runs for anything the
+// editor can produce — a draft must read back exactly as it was styled.
+final class RichTextEditTests: XCTestCase {
+ private func roundTrips(_ runs: [RichRun], _ note: String = "") {
+ XCTAssertEqual(parseRuns(serializeRuns(runs)), runs, note)
+ }
+
+ func testPlainTextSerializesVerbatim() {
+ XCTAssertEqual(serializeRuns([RichRun("hello world")]), "hello world")
+ // Typed markers in a plain run pass through untouched (same as the web
+ // composer: what you type is what is sent).
+ XCTAssertEqual(serializeRuns([RichRun("a **manual** marker")]), "a **manual** marker")
+ }
+
+ func testMarkersPerStyle() {
+ XCTAssertEqual(serializeRuns([RichRun("b", bold: true)]), "**b**")
+ XCTAssertEqual(serializeRuns([RichRun("i", italic: true)]), "*i*")
+ XCTAssertEqual(serializeRuns([RichRun("bi", bold: true, italic: true)]), "***bi***")
+ XCTAssertEqual(serializeRuns([RichRun("s", strike: true)]), "~s~")
+ XCTAssertEqual(serializeRuns([RichRun("c", code: true)]), "`c`")
+ }
+
+ func testItalicSerializesAsAsteriskForMidWordSafety() {
+ // `_` needs word boundaries in the grammar; `*` renders anywhere.
+ let s = serializeRuns([RichRun("un"), RichRun("believ", italic: true), RichRun("able")])
+ XCTAssertEqual(s, "un*believ*able")
+ XCTAssertEqual(parseRuns(s), [RichRun("un"), RichRun("believ", italic: true), RichRun("able")])
+ }
+
+ func testStyledRunSplitsAtNewlines() {
+ // Markers can't span lines, so each line wraps on its own.
+ XCTAssertEqual(serializeRuns([RichRun("one\ntwo", bold: true)]), "**one**\n**two**")
+ XCTAssertEqual(serializeRuns([RichRun("a\n\nb", italic: true)]), "*a*\n\n*b*")
+ }
+
+ func testWhitespaceOnlyStretchesStayUnwrapped() {
+ XCTAssertEqual(serializeRuns([RichRun(" ", bold: true)]), " ")
+ XCTAssertEqual(serializeRuns([RichRun("a\n \nb", bold: true)]), "**a**\n \n**b**")
+ }
+
+ func testInexpressibleCombosResolveByPriority() {
+ // code wins outright; strike drops when combined with bold/italic.
+ XCTAssertEqual(serializeRuns([RichRun("x", bold: true, code: true)]), "`x`")
+ XCTAssertEqual(serializeRuns([RichRun("x", bold: true, strike: true)]), "**x**")
+ XCTAssertEqual(serializeRuns([RichRun("x", italic: true, strike: true)]), "*x*")
+ }
+
+ func testParseMirrorsTheFeed() {
+ XCTAssertEqual(parseRuns("a **b** *i* ***bi*** ~s~ `c`"), [
+ RichRun("a "),
+ RichRun("b", bold: true),
+ RichRun(" "),
+ RichRun("i", italic: true),
+ RichRun(" "),
+ RichRun("bi", bold: true, italic: true),
+ RichRun(" "),
+ RichRun("s", strike: true),
+ RichRun(" "),
+ RichRun("c", code: true),
+ ])
+ }
+
+ func testLinksFlattenToPlainTextAndCoalesce() {
+ // The link and its neighbours read back as one plain run — the composer
+ // doesn't style links, and their text must survive verbatim.
+ XCTAssertEqual(parseRuns("see https://x.com now"), [RichRun("see https://x.com now")])
+ }
+
+ func testUnderscoreItalicCanonicalizesToAsterisk() {
+ // Not a round trip — parse reads `_i_`, serialize re-emits `*i*`.
+ XCTAssertEqual(serializeRuns(parseRuns("_i_")), "*i*")
+ }
+
+ func testRoundTripsEverythingTheEditorProduces() {
+ roundTrips([RichRun("plain")])
+ roundTrips([RichRun("a "), RichRun("b", bold: true), RichRun(" c")])
+ roundTrips([RichRun("x", bold: true, italic: true)], "combined bold italic")
+ roundTrips([
+ RichRun("mixed "),
+ RichRun("bold", bold: true),
+ RichRun(" and "),
+ RichRun("both", bold: true, italic: true),
+ RichRun(" then\nplain line two"),
+ ])
+ roundTrips([RichRun("líne ✨ emoji", italic: true)], "unicode survives")
+ }
+
+ func testMultiLineStyledRunsCanonicalizeStably() {
+ // A styled run spanning a newline splits per line (markers can't span
+ // lines), so its parse is the canonical split form — the newline itself
+ // is a plain run, which styles identically in the editor (styling on a
+ // newline is invisible). What must hold is stability from then on:
+ // serializing the canonical form reproduces the same markers.
+ let s = serializeRuns([RichRun("two\nlines", bold: true), RichRun(" tail")])
+ XCTAssertEqual(s, "**two**\n**lines** tail")
+ let canonical = parseRuns(s)
+ XCTAssertEqual(canonical, [
+ RichRun("two", bold: true),
+ RichRun("\n"),
+ RichRun("lines", bold: true),
+ RichRun(" tail"),
+ ])
+ XCTAssertEqual(serializeRuns(canonical), s, "canonical form is a fixed point")
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift
index 66aee79..65511b1 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift
@@ -58,6 +58,15 @@ final class RichTextTests: XCTestCase {
XCTAssertEqual(richTokens("well ~nope~ then"), [.text("well "), .strike("nope"), .text(" then")])
}
+ // test/format.test.mjs "triple asterisk is combined bold italic"
+ func testTripleAsteriskIsCombinedBoldItalic() {
+ XCTAssertEqual(richTokens("***both***"), [.boldItalic("both")])
+ XCTAssertEqual(richTokens("a ***b c*** d"), [.text("a "), .boldItalic("b c"), .text(" d")])
+ // Neighbours don't bleed: bi next to bold, and markers still can't span lines.
+ XCTAssertEqual(richTokens("***x*** **y**"), [.boldItalic("x"), .text(" "), .bold("y")])
+ XCTAssertEqual(richTokens("***a\nb***"), [.text("***a\nb***")])
+ }
+
func testUnderscoreItalicsNeedWordBoundaries() {
// file_names must not italicize (format.js's lookbehind/lookahead).
XCTAssertEqual(richTokens("see file_name_here"), [.text("see file_name_here")])
diff --git a/ios/LChatMac/FrostedBand.swift b/ios/LChatMac/FrostedBand.swift
new file mode 100644
index 0000000..c14efd6
--- /dev/null
+++ b/ios/LChatMac/FrostedBand.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+import AppKit
+
+// The within-window material glass Messages.app uses for its chat header and
+// composer footer: an NSVisualEffectView blending within the window, frosting
+// whatever scrolls behind it. Deliberately NO colour of its own — a tint can
+// only ever mismatch the feed (it did: windowBackgroundColor isn't what the
+// pane actually sits on). A blur of a solid is that solid, so the glass
+// matches the transcript at rest and frosts what passes beneath.
+//
+// ⚠️ Two dead ends, so nobody re-walks them:
+// - alphaValue < 1 on an NSVisualEffectView silently DISABLES its backdrop
+// blur — you get a faded plate with sharp content bleeding through, not a
+// lighter frost. The band must run at exactly 1.
+// - A true CSS-style backdrop-filter (pure gaussian via public
+// CALayer.backgroundFilters, no material plate) is compositor-ignored in
+// this window tree; only the private CABackdropLayer gets backdrop
+// sampling, and we don't ship private API.
+struct FrostedBand: View {
+ var body: some View { WithinWindowBlur() }
+}
+
+private struct WithinWindowBlur: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSVisualEffectView {
+ let v = NSVisualEffectView()
+ v.blendingMode = .withinWindow
+ v.material = .headerView
+ v.state = .active
+ return v
+ }
+ func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
+}
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
new file mode 100644
index 0000000..e309ff3
--- /dev/null
+++ b/ios/LChatMac/MacComposerField.swift
@@ -0,0 +1,403 @@
+import SwiftUI
+import AppKit
+import LChatCore
+
+// The Mac native composer's text input — the NSTextView-backed field the old
+// SwiftUI TextField couldn't be (no selection access, no attributed text, no
+// caret for the typeahead). The AppKit twin of iOS's ComposerField, sharing its
+// contract exactly:
+//
+// - Rich text in the box: ⌘B/⌘I toggle bold/italic on the selection (or the
+// typing style at a bare caret), like Messages. ⌘U is swallowed — the marker
+// grammar has no underline, and a style that silently vanishes on send would
+// be a lie.
+// - The `text` binding stays a plain String holding the wire's marker form
+// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so the send path,
+// slash commands and drafts are untouched. Serialize on every change; parse
+// on external sets (an edit-load shows glyphs, not asterisks).
+// - Return sends, Shift/Option+Return inserts a newline (the desktop
+// shouldSend split); the typeahead gets first claim on ↑↓/Tab/Return/Esc via
+// the handler closures, and ↑ on an empty draft edits your last message.
+// - `caret` reports a UTF-16 offset into the *serialized* text — real caret
+// tracking for the typeahead (the old field could only assume caret-at-end).
+struct MacComposerField: NSViewRepresentable {
+ @Binding var text: String
+ @Binding var focused: Bool
+ var caret: Binding<Int>? = nil
+ var placeholder: String = ""
+ var fontSize: CGFloat
+ var isDisabled: Bool = false
+ var onSend: () -> Void
+ // Typeahead/shortcut hooks — each returns whether it consumed the key.
+ var onMoveUp: () -> Bool = { false }
+ var onMoveDown: () -> Bool = { false }
+ var onAccept: () -> Bool = { false } // Tab / Return over an open typeahead
+ var onEscape: () -> Bool = { false }
+ // Stage images / file URLs from a paste; returns whether anything staged.
+ // nil in a default-command thread (text-only surface, like the web).
+ var onStagePaste: ((NSPasteboard) -> Bool)? = nil
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ func makeNSView(context: Context) -> NSScrollView {
+ let tv = ComposerTextView()
+ tv.baseFontSize = fontSize
+ tv.delegate = context.coordinator
+ tv.onFocusChange = { [weak coordinator = context.coordinator] has in
+ guard let parent = coordinator?.parent else { return }
+ if parent.focused != has { parent.focused = has }
+ }
+ tv.isRichText = true // attributed editing; pastes are flattened below
+ tv.allowsUndo = true
+ tv.drawsBackground = false
+ tv.textContainerInset = .zero
+ tv.textContainer?.lineFragmentPadding = 0
+ tv.textContainer?.widthTracksTextView = true
+ tv.isVerticallyResizable = true
+ tv.isHorizontallyResizable = false
+ tv.autoresizingMask = [.width]
+ // The classic scroll-view pairing: without an unbounded maxSize the
+ // text view can't grow past its initial frame, and content beyond it
+ // would neither lay out nor scroll.
+ tv.minSize = .zero
+ tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude,
+ height: CGFloat.greatestFiniteMagnitude)
+ tv.usesFontPanel = false
+ tv.importsGraphics = false
+ tv.isAutomaticLinkDetectionEnabled = false // a link attribute we'd never serialize
+ tv.applyBaseTypingAttributes()
+
+ let scroll = NSScrollView()
+ scroll.documentView = tv
+ scroll.drawsBackground = false
+ scroll.borderType = .noBorder
+ scroll.hasVerticalScroller = true
+ scroll.autohidesScrollers = true
+ return scroll
+ }
+
+ func updateNSView(_ scroll: NSScrollView, context: Context) {
+ context.coordinator.parent = self
+ guard let tv = scroll.documentView as? ComposerTextView else { return }
+ tv.onStagePaste = onStagePaste
+ tv.isEditable = !isDisabled
+ if tv.baseFontSize != fontSize {
+ tv.baseFontSize = fontSize
+ tv.setMarkerText(text) // re-derive every run from the new base size
+ }
+ // Compare serialized forms: user edits round-trip exactly, so only
+ // *external* sets (edit-load, clear after send, typeahead splice)
+ // reparse the view. Canonicalize the target so a draft arriving as
+ // `_i_` (serializes back as `*i*`) doesn't re-set on every update.
+ if tv.serializedText() != serializeRuns(parseRuns(text)) {
+ tv.setMarkerText(text)
+ }
+ tv.placeholderLabel.stringValue = placeholder
+ tv.placeholderLabel.isHidden = !text.isEmpty
+ // Drive first responder from the focus binding — async so we never
+ // mutate responder state mid-update. Compare against the live first
+ // responder so a re-assert to `true` still re-grabs focus.
+ let isFirst = tv.window?.firstResponder === tv
+ if focused != isFirst {
+ DispatchQueue.main.async {
+ guard let window = tv.window else { return }
+ if focused {
+ window.makeFirstResponder(tv)
+ } else if window.firstResponder === tv {
+ window.makeFirstResponder(nil)
+ }
+ }
+ }
+ }
+
+ // Grow with the text up to nine lines (the old field's lineLimit(1...9)),
+ // then scroll within. ALWAYS returns a finite, capped height — SwiftUI
+ // probes with zero/unspecified/infinite proposals, and returning nil for
+ // any of them marks the view as greedy: the field then swallows the whole
+ // pane (the giant-composer bug).
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? {
+ guard let tv = nsView.documentView as? ComposerTextView,
+ let container = tv.textContainer, let layout = tv.layoutManager else { return nil }
+ // Resolve a working width: the proposal when it's a real number, else
+ // whatever the view last had (a probe must still yield a finite height).
+ var width = proposal.width ?? tv.bounds.width
+ if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) }
+ if tv.frame.width != width { tv.setFrameSize(NSSize(width: width, height: tv.frame.height)) }
+ layout.ensureLayout(for: container)
+ let line = layout.defaultLineHeight(for: tv.baseFont)
+ let used = layout.usedRect(for: container).height
+ let height = ceil(min(max(line, used), ceil(line * 9)))
+ // Width: honor a finite proposal exactly; for zero/infinite probes
+ // report the working width rather than echoing the probe back.
+ if let w = proposal.width, w.isFinite, w > 0 {
+ return CGSize(width: w, height: height)
+ }
+ return CGSize(width: width, height: height)
+ }
+
+ final class Coordinator: NSObject, NSTextViewDelegate {
+ var parent: MacComposerField
+ init(_ parent: MacComposerField) { self.parent = parent }
+
+ func textDidChange(_ notification: Notification) {
+ guard let tv = notification.object as? ComposerTextView else { return }
+ parent.text = tv.serializedText()
+ parent.caret?.wrappedValue = tv.serializedCaret()
+ tv.placeholderLabel.isHidden = !tv.string.isEmpty
+ }
+
+ func textViewDidChangeSelection(_ notification: Notification) {
+ guard let tv = notification.object as? ComposerTextView else { return }
+ parent.caret?.wrappedValue = tv.serializedCaret()
+ }
+
+ func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+ switch commandSelector {
+ case #selector(NSResponder.insertNewline(_:)):
+ // Desktop shouldSend: Shift/Option+Return newlines (fall through
+ // to the standard insertion), plain Return accepts the typeahead
+ // or sends.
+ let mods = NSApp.currentEvent?.modifierFlags ?? []
+ if mods.contains(.shift) || mods.contains(.option) { return false }
+ if parent.onAccept() { return true }
+ parent.onSend()
+ return true
+ case #selector(NSResponder.moveUp(_:)):
+ return parent.onMoveUp()
+ case #selector(NSResponder.moveDown(_:)):
+ return parent.onMoveDown()
+ case #selector(NSResponder.insertTab(_:)):
+ return parent.onAccept()
+ case #selector(NSResponder.cancelOperation(_:)):
+ return parent.onEscape()
+ case #selector(NSResponder.complete(_:)):
+ return true // ours is the only completion UI — never the system popup
+ default:
+ return false
+ }
+ }
+ }
+}
+
+// The NSTextView itself: placeholder, ⌘B/⌘I trait toggles, flattened pastes,
+// and the styled-contents ↔ marker-grammar conversion (RichTextEdit.swift).
+final class ComposerTextView: NSTextView {
+ let placeholderLabel = NSTextField(labelWithString: "")
+ var onStagePaste: ((NSPasteboard) -> Bool)?
+ var onFocusChange: ((Bool) -> Void)?
+
+ var baseFontSize: CGFloat = NSFont.systemFontSize {
+ didSet {
+ placeholderLabel.font = .systemFont(ofSize: baseFontSize)
+ if string.isEmpty { applyBaseTypingAttributes() }
+ }
+ }
+ // The unstyled body font every run's traits derive from. Never `font` —
+ // after styled content lands, that reports the run at the caret, and
+ // deriving from a bold base would bold everything.
+ var baseFont: NSFont { .systemFont(ofSize: baseFontSize) }
+ private var baseColor: NSColor { .labelColor }
+
+ override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
+ super.init(frame: frameRect, textContainer: container)
+ setUpPlaceholder()
+ }
+ // AppKit (unlike UIKit) does NOT build the text system for a nil container
+ // — init(frame:textContainer: nil) yields a view with no textContainer, no
+ // layoutManager, no textStorage: typing is dead and sizeThatFits' guards
+ // bail (which read as "greedy", the composer-swallows-the-pane bug).
+ // Assemble the classic TextKit stack by hand.
+ convenience init() {
+ let storage = NSTextStorage()
+ let layout = NSLayoutManager()
+ storage.addLayoutManager(layout)
+ let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
+ container.widthTracksTextView = true
+ layout.addTextContainer(container)
+ self.init(frame: .zero, textContainer: container)
+ }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ private func setUpPlaceholder() {
+ placeholderLabel.font = .systemFont(ofSize: baseFontSize)
+ placeholderLabel.textColor = .placeholderTextColor
+ placeholderLabel.lineBreakMode = .byTruncatingTail
+ placeholderLabel.maximumNumberOfLines = 1
+ addSubview(placeholderLabel)
+ }
+
+ override func layout() {
+ super.layout()
+ placeholderLabel.frame = NSRect(x: 0, y: 0, width: bounds.width,
+ height: placeholderLabel.intrinsicContentSize.height)
+ }
+
+ func applyBaseTypingAttributes() {
+ typingAttributes = [.font: baseFont, .foregroundColor: baseColor]
+ }
+
+ override func becomeFirstResponder() -> Bool {
+ let ok = super.becomeFirstResponder()
+ if ok { onFocusChange?(true) }
+ return ok
+ }
+ override func resignFirstResponder() -> Bool {
+ let ok = super.resignFirstResponder()
+ if ok { onFocusChange?(false) }
+ return ok
+ }
+
+ // ⌘B/⌘I toggle traits; ⌘U is swallowed (no underline marker — see the
+ // representable's doc comment). Everything else keeps its meaning.
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
+ if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
+ let key = event.charactersIgnoringModifiers?.lowercased() {
+ switch key {
+ case "b": toggleTrait(.bold); return true
+ case "i": toggleTrait(.italic); return true
+ case "u": return true
+ default: break
+ }
+ }
+ return super.performKeyEquivalent(with: event)
+ }
+
+ // Toggle a symbolic trait over the selection (all-styled → remove, else
+ // add — the standard semantics), or flip the typing style at a bare caret.
+ // Selection changes go through shouldChangeText/didChangeText so undo works
+ // and the delegate (→ the binding) hears about the attribute-only edit.
+ private func toggleTrait(_ trait: NSFontDescriptor.SymbolicTraits) {
+ let range = selectedRange()
+ if range.length == 0 {
+ var attrs = typingAttributes
+ let font = (attrs[.font] as? NSFont) ?? baseFont
+ let has = font.fontDescriptor.symbolicTraits.contains(trait)
+ attrs[.font] = converted(font, trait: trait, on: !has)
+ typingAttributes = attrs
+ return
+ }
+ guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return }
+ var allStyled = true
+ storage.enumerateAttribute(.font, in: range) { value, _, stop in
+ let font = (value as? NSFont) ?? baseFont
+ if !font.fontDescriptor.symbolicTraits.contains(trait) {
+ allStyled = false
+ stop.pointee = true
+ }
+ }
+ storage.beginEditing()
+ storage.enumerateAttribute(.font, in: range) { value, subRange, _ in
+ let font = (value as? NSFont) ?? baseFont
+ storage.addAttribute(.font, value: converted(font, trait: trait, on: !allStyled), range: subRange)
+ }
+ storage.endEditing()
+ didChangeText()
+ }
+
+ private func converted(_ font: NSFont, trait: NSFontDescriptor.SymbolicTraits, on: Bool) -> NSFont {
+ var traits = font.fontDescriptor.symbolicTraits
+ if on { traits.insert(trait) } else { traits.remove(trait) }
+ let descriptor = font.fontDescriptor.withSymbolicTraits(traits)
+ return NSFont(descriptor: descriptor, size: font.pointSize) ?? font
+ }
+
+ // Pastes: stage images/file URLs as uploads (the web composer's
+ // paste-to-upload), and flatten any styled text to the caret's typing
+ // style — isRichText would otherwise keep a web page's fonts/colors, which
+ // the marker grammar can't represent. A mixed clipboard does both.
+ override func paste(_ sender: Any?) {
+ let pb = NSPasteboard.general
+ let staged = onStagePaste?(pb) ?? false
+ if let s = pb.string(forType: .string), !s.isEmpty {
+ insertText(s, replacementRange: selectedRange())
+ return
+ }
+ if !staged { super.pasteAsPlainText(sender) }
+ }
+
+ // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ----
+
+ // The style of the character at a UTF-16 index, as an empty-text run.
+ private func styleAt(_ index: Int) -> RichRun {
+ guard let storage = textStorage else { return RichRun("") }
+ let attrs = storage.attributes(at: index, effectiveRange: nil)
+ let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits
+ let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
+ return RichRun("", bold: traits.contains(.bold), italic: traits.contains(.italic),
+ strike: strike, code: traits.contains(.monoSpace))
+ }
+
+ private func runs(of a: NSAttributedString) -> [RichRun] {
+ var runs: [RichRun] = []
+ let str = a.string as NSString
+ a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in
+ let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits
+ let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
+ let run = RichRun(str.substring(with: range),
+ bold: traits.contains(.bold), italic: traits.contains(.italic),
+ strike: strike, code: traits.contains(.monoSpace))
+ // Coalesce: AppKit splits runs on attributes (color, kerning…) that
+ // don't matter to the grammar.
+ if let last = runs.last, last.sameStyle(as: run) {
+ runs[runs.count - 1].text += run.text
+ } else {
+ runs.append(run)
+ }
+ }
+ return runs
+ }
+
+ /// The field's contents in wire form — what the `text` binding holds.
+ func serializedText() -> String {
+ serializeRuns(runs(of: textStorage ?? NSAttributedString()))
+ }
+
+ /// Replace the contents from wire form (an edit-load / external set): the
+ /// draft shows glyphs, not asterisks.
+ func setMarkerText(_ text: String) {
+ let out = NSMutableAttributedString()
+ for run in parseRuns(text) {
+ var traits = baseFont.fontDescriptor.symbolicTraits
+ if run.bold { traits.insert(.bold) }
+ if run.italic { traits.insert(.italic) }
+ var font = baseFont
+ if run.code {
+ font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
+ if run.bold || run.italic {
+ font = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(traits),
+ size: 0) ?? font
+ }
+ } else if run.bold || run.italic {
+ font = NSFont(descriptor: baseFont.fontDescriptor.withSymbolicTraits(traits),
+ size: 0) ?? font
+ }
+ var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor]
+ if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
+ out.append(NSAttributedString(string: run.text, attributes: attrs))
+ }
+ textStorage?.setAttributedString(out)
+ setSelectedRange(NSRange(location: out.length, length: 0))
+ // A cleared field must not inherit the last run's style into the next
+ // message — reset the caret style to plain.
+ if text.isEmpty { applyBaseTypingAttributes() }
+ }
+
+ /// The caret as an offset into the *serialized* text. Serializing the prefix
+ /// before the caret counts every marker the caret sits after; when the caret
+ /// is strictly inside a styled run, that prefix closed the run's markers
+ /// behind it — characters the true position is before, so back them off.
+ func serializedCaret() -> Int {
+ guard let storage = textStorage else { return 0 }
+ let loc = selectedRange().location
+ guard loc > 0, loc <= storage.length else { return 0 }
+ let prefix = storage.attributedSubstring(from: NSRange(location: 0, length: loc))
+ var n = (serializeRuns(runs(of: prefix)) as NSString).length
+ if loc < storage.length {
+ let before = styleAt(loc - 1)
+ if before.sameStyle(as: styleAt(loc)), let marker = before.marker {
+ n -= (marker as NSString).length
+ }
+ }
+ return n
+ }
+}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index d05c97d..3e8a17e 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -305,13 +305,30 @@ struct NativeChatView: View {
// The feed column: the web's chat header (avatar, title, ⋮ with pin/close)
// above the messages.
- private var feedColumn: some View {
- VStack(spacing: 0) {
- if let thread = selectedThread {
- feedHeader(thread)
- Divider()
- }
+ @ViewBuilder private var feedColumn: some View {
+ if let thread = selectedThread, !thread.isApp, !isCompanion(thread) {
+ // Plain chats: the header rides OVER the feed as a safe-area inset,
+ // frosted with a within-window blur, so messages visibly scroll
+ // beneath it — Messages.app's header treatment (see FrostedBand).
feed
+ .safeAreaInset(edge: .top, spacing: 0) {
+ VStack(spacing: 0) {
+ feedHeader(thread)
+ Divider()
+ }
+ .background(FrostedBand())
+ }
+ } else {
+ // App/companion surfaces host webviews that fill their frame (an
+ // NSView doesn't honor safe areas), so their header stays a
+ // stacked band above the pane.
+ VStack(spacing: 0) {
+ if let thread = selectedThread {
+ feedHeader(thread)
+ Divider()
+ }
+ feed
+ }
}
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 4f8f445..14cf47a 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -93,7 +93,10 @@ struct NativeFeedView: View {
// The native composer's draft — cleared only when a send actually reaches
// the server (model.send returns false on a down socket, keeping the text).
@State private var draft = ""
- @FocusState private var composerFocused: Bool
+ @State private var composerFocused = false
+ // The caret as a UTF-16 offset into `draft` (MacComposerField maps its
+ // display caret to serialized offsets) — drives the typeahead.
+ @State private var composerCaret = 0
// Attachments staged on the draft: picked → uploading (spinner) → ready.
// Several can ride one message, like the web's pending list.
private struct Staged: Identifiable {
@@ -680,26 +683,39 @@ struct NativeFeedView: View {
}
}
.frame(maxWidth: .infinity)
- // iMessage-style: the input row rides ONE translucent band, so the
- // feed shows through it as material blur instead of fully-legible
- // text (worst in dark mode). Only the row — the typeahead/banners
- // above stay transparent floaters.
- .background(.bar)
+ // iMessage-style: a within-window frost (FrostedBand) — matches the
+ // chat stream's surface exactly at rest (blurring the same window
+ // background it sits on) while messages scrolling underneath ghost
+ // through the glass. (The old `.bar` material looked like a second,
+ // lighter surface; an opaque fill killed the ghosting.) Only the
+ // input row wears it — the typeahead/banners above stay
+ // transparent floaters.
+ .background(FrostedBand())
}
.onChange(of: draft) { _, text in
- // Caret-at-end is the common case while typing; caret tracking for
- // mid-text edits needs the NSTextView-backed field (iOS's
- // ComposerField pattern) — container work.
- taToken = composerToken(text)
- taItems = taToken.map {
- typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
- appCommands: appCommands)
- } ?? []
- taIndex = 0
+ refreshTypeahead()
// The typing beats: alive while there's a draft, an immediate
// clear when it empties (edits don't announce themselves).
model.typingBeat(active: !text.isEmpty && editingID == nil)
}
+ // Caret moves (clicks / arrows) can enter or leave a token with the
+ // text unchanged — re-check, like the web composer's keyup handler.
+ .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
+ }
+
+ // The token under the REAL caret (MacComposerField tracks it) — the old
+ // TextField could only assume caret-at-end. The field reports UTF-16, the
+ // tokenizer speaks Characters, so convert at the boundary (as on iOS).
+ private func refreshTypeahead() {
+ let utf16 = min(max(composerCaret, 0), draft.utf16.count)
+ let idx = String.Index(utf16Offset: utf16, in: draft)
+ let charCaret = draft.distance(from: draft.startIndex, to: idx)
+ taToken = composerToken(draft, caret: charCaret)
+ taItems = taToken.map {
+ typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
+ appCommands: appCommands)
+ } ?? []
+ taIndex = 0
}
// Accept a candidate: splice it over the token (trigger → caret), exactly
@@ -808,82 +824,79 @@ struct NativeFeedView: View {
Button(action: pickFiles) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28)) // same weight as the send button
- .foregroundStyle(.tertiary)
+ // Messages.app: a real glyph on the disc — white in
+ // dark mode, dark grey in light — not the knocked-out
+ // transparent plus the monochrome fill gave.
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3),
+ .tertiary)
}
.buttonStyle(.plain)
.padding(.bottom, 4)
}
- TextField(defaultCommand?.placeholder ?? "Message", text: $draft, axis: .vertical)
- // Paste an image (or a copied file) straight onto the draft -
- // it stages as an upload, like the web composer's paste. In a
- // default-command thread the type list is empty, so ⌘V is NOT
- // claimed here and the field's normal text paste still works.
- .onPasteCommand(of: defaultCommand == nil ? [.png, .jpeg, .tiff, .image, .fileURL] : []) { providers in
- handlePaste(providers)
- }
- .textFieldStyle(.plain)
- .font(.system(size: chatFontSize)) // the feed's reading size, like the web composer
- .lineLimit(1...9) // grows to ~9 lines, then scrolls within itself
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- // The web composer: a bordered pill on the panel background,
- // not a filled grey field.
- .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .fill(.background))
- .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
- .onSubmit(sendDraft)
+ // The NSTextView-backed field (MacComposerField): rich text in the
+ // box (⌘B/⌘I serialize to markers in `draft`), real caret tracking
+ // for the typeahead, and paste-to-upload — the SwiftUI TextField it
+ // replaced could do none of those. Typeahead keys route through the
+ // handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
+ // falls through to send when no menu is up. Up-arrow ALSO edits the
+ // last message when the box is empty.
+ MacComposerField(
+ text: $draft,
+ focused: $composerFocused,
+ caret: $composerCaret,
+ placeholder: defaultCommand?.placeholder ?? "Message",
+ fontSize: chatFontSize, // the feed's reading size, like the web composer
// Queued behind an upload = the message is committed; editing
// what's about to auto-send would race it.
- .disabled(sendQueued)
- // Typeahead keys: ↑↓ move, Tab/Return accept, Esc dismisses —
- // Return falls through to send when no menu is up. Up-arrow
- // ALSO edits the last message when the box is empty (one
- // handler — two .onKeyPress(.upArrow) on a field don't both
- // fire, which is why the edit shortcut never worked).
- .onKeyPress(.upArrow) {
+ isDisabled: sendQueued,
+ onSend: sendDraft,
+ onMoveUp: {
if !taItems.isEmpty {
taIndex = (taIndex + taItems.count - 1) % taItems.count
- return .handled
+ return true
}
if draft.isEmpty, editingID == nil,
let last = model.messages.last(where: { $0.mine && !$0.action }) {
startEdit(last)
- return .handled
+ return true
}
- return .ignored
- }
- .onKeyPress(.downArrow) {
- guard !taItems.isEmpty else { return .ignored }
+ return false
+ },
+ onMoveDown: {
+ guard !taItems.isEmpty else { return false }
taIndex = (taIndex + 1) % taItems.count
- return .handled
- }
- .onKeyPress(.tab) {
- guard !taItems.isEmpty, let token = taToken else { return .ignored }
+ return true
+ },
+ onAccept: {
+ guard !taItems.isEmpty, let token = taToken else { return false }
pick(taItems[taIndex], token: token)
- return .handled
- }
- .onKeyPress(phases: .down) { press in
- guard press.key == .return else { return .ignored }
- // Desktop shouldSend: Shift/Option+Return newlines, plain
- // Return accepts the typeahead or sends (via onSubmit).
- if press.modifiers.contains(.shift) || press.modifiers.contains(.option) {
- draft += "\n"
- return .handled
- }
- if !taItems.isEmpty, let token = taToken {
- pick(taItems[taIndex], token: token)
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.escape) {
- guard !taItems.isEmpty else { return .ignored }
+ return true
+ },
+ onEscape: {
+ guard !taItems.isEmpty else { return false }
taToken = nil
taItems = []
- return .handled
- }
- .focused($composerFocused)
+ return true
+ },
+ // Paste an image (or a copied file) straight onto the draft —
+ // it stages as an upload, like the web composer's paste. Absent
+ // in a default-command thread (text-only surface), so a text
+ // paste there stays a plain text paste.
+ onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil
+ )
+ // Claim the row's full width (the + and send buttons keep their
+ // fixed sizes) — the representable reports its measured width at
+ // the max probe, so without this the HStack centres a short pill.
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ // The web composer: a bordered pill on the panel background,
+ // not a filled grey field.
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
// Regaining the app drops the caret into the message box, the
// same courtesy the webview shell extends (focusComposer) —
// but ONLY from the pane actually on screen (every instance's
@@ -926,7 +939,9 @@ struct NativeFeedView: View {
.disabled(!hasContent)
.padding(.bottom, 4)
}
- .padding(EdgeInsets(top: 8, leading: 10, bottom: 6, trailing: 10))
+ // Messages.app gives the input pill real breathing room below — the
+ // old 6pt read as cramped against the window edge.
+ .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
}
private var hasContent: Bool {
@@ -1215,27 +1230,24 @@ struct NativeFeedView: View {
}
}
- // Pasted content: file URLs route through stage(url); raw image data
- // stages as a PNG (like the web composer's paste-to-upload).
- private func handlePaste(_ providers: [NSItemProvider]) {
- for provider in providers {
- if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
- _ = provider.loadObject(ofClass: URL.self) { url, _ in
- if let url { DispatchQueue.main.async { stage(url) } }
- }
- continue
- }
- let types: [UTType] = [.png, .jpeg, .tiff, .image]
- guard let type = types.first(where: {
- provider.hasItemConformingToTypeIdentifier($0.identifier)
- }) else { continue }
- provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
- guard let data else { return }
- DispatchQueue.main.async {
- stage(data: data, name: "pasted.png", type: .png)
- }
+ // Pasted content (from MacComposerField's paste override): file URLs route
+ // through stage(url); raw image data stages as a PNG (like the web
+ // composer's paste-to-upload). Returns whether anything staged, so the
+ // field knows if the paste was (partly) consumed.
+ private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
+ if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
+ let files = urls.filter(\.isFileURL)
+ if !files.isEmpty {
+ files.forEach { stage($0) }
+ return true
}
}
+ for type in [NSPasteboard.PasteboardType.png, .tiff] {
+ guard let data = pb.data(forType: type) else { continue }
+ stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff)
+ return true
+ }
+ return false
}
private func stage(_ url: URL) {
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 95d3657..618af76 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -65,13 +65,28 @@ struct RootView: View {
// A rail entry with no native identity: signing in again pulls the key
// from the member's Eleven account; there is deliberately no web fallback.
+ // Sign-in is the pane's PRIMARY action — the copy tells you to sign in, so
+ // the button must be right here, not hidden in Settings (removal stays as
+ // the destructive escape hatch).
private func lockedPane(_ chat: SavedChat) -> some View {
ContentUnavailableView {
Label(app.serverName(chat), systemImage: "lock")
} description: {
Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.")
} actions: {
+ if firstRunSignIn.phase.busy {
+ ProgressView().controlSize(.small)
+ } else {
+ Button("Sign In with Eleven…") {
+ Task { await firstRunSignIn.signIn(into: app) }
+ }
+ .buttonStyle(.borderedProminent)
+ }
Button("Remove from This Mac", role: .destructive) { app.remove(chat) }
+ if case .failed(let msg) = firstRunSignIn.phase {
+ Text(msg).font(.footnote).foregroundStyle(.red)
+ .frame(maxWidth: 420)
+ }
}
}
diff --git a/test/format.test.mjs b/test/format.test.mjs
index a9aa3c1..83301c6 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -177,6 +177,22 @@ await test("richTokens: bold beats italic at a double asterisk", () => {
assert.deepEqual(richTokens("**b**"), [{ text: "b", mark: "b" }]);
});
+await test("richTokens: triple asterisk is combined bold italic", () => {
+ assert.deepEqual(richTokens("***both***"), [{ text: "both", mark: "bi" }]);
+ assert.deepEqual(richTokens("a ***b c*** d"), [
+ { text: "a " },
+ { text: "b c", mark: "bi" },
+ { text: " d" },
+ ]);
+ // Neighbours don't bleed: bi next to bold, and markers still can't span lines.
+ assert.deepEqual(richTokens("***x*** **y**"), [
+ { text: "x", mark: "bi" },
+ { text: " " },
+ { text: "y", mark: "b" },
+ ]);
+ assert.deepEqual(richTokens("***a\nb***"), [{ text: "***a\nb***" }]);
+});
+
await test("richTokens: mixes plain text, a mark, and a link", () => {
const t = richTokens("hi *there* see https://x.com");
assert.deepEqual(t[0], { text: "hi " });
diff --git a/web/static/apps.js b/web/static/apps.js
index ca3b965..3b27653 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -1303,6 +1303,13 @@ function renderNoticeBody(text, el) {
a.rel = "noopener noreferrer";
a.textContent = tok.text;
el.appendChild(a);
+ } else if (tok.mark === "bi") {
+ // ***bold italic*** — the one combined mark: em nested inside strong.
+ const b = document.createElement("strong");
+ const i = document.createElement("em");
+ i.textContent = tok.text;
+ b.appendChild(i);
+ el.appendChild(b);
} else if (tok.mark) {
const w = document.createElement(NOTICE_MARK_TAG[tok.mark]);
w.textContent = tok.text;
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 2ebd37b..406587b 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -1603,6 +1603,13 @@ function renderRichText(el, text) {
a.rel = "noopener noreferrer";
a.textContent = tok.text;
target.appendChild(a);
+ } else if (tok.mark === "bi") {
+ // ***bold italic*** — the one combined mark: em nested inside strong.
+ const b = document.createElement("strong");
+ const i = document.createElement("em");
+ i.textContent = tok.text;
+ b.appendChild(i);
+ target.appendChild(b);
} else if (tok.mark) {
const w = document.createElement(MARK_TAG[tok.mark]);
w.textContent = tok.text;
diff --git a/web/static/format.js b/web/static/format.js
index fe4ffe5..df2a8d2 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -67,12 +67,13 @@ export function tokenizeText(text) {
return tokens;
}
-// Inline formatting markers (single level, no nesting):
-// `code` **bold** *italic* _italic_ ~strike~
+// Inline formatting markers (single level, no nesting — except the one
+// combined form, ***bold italic***, which is its own marker, not nesting):
+// `code` ***bold italic*** **bold** *italic* _italic_ ~strike~
// Underscore italics require word boundaries so file_names don't italicize.
// (Hyphens are deliberately NOT a marker — they appear too often in normal text,
// e.g. dates and "well-known", to be a safe delimiter.)
-const FORMAT_RE = /`([^`\n]+)`|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g;
+const FORMAT_RE = /`([^`\n]+)`|\*\*\*([^*\n]+)\*\*\*|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g;
function formatSegment(text, out) {
let last = 0;
@@ -80,10 +81,11 @@ function formatSegment(text, out) {
if (m.index > last) out.push({ text: text.slice(last, m.index) });
let mark, content;
if (m[1] != null) [mark, content] = ["code", m[1]];
- else if (m[2] != null) [mark, content] = ["b", m[2]];
- else if (m[3] != null) [mark, content] = ["i", m[3]];
+ else if (m[2] != null) [mark, content] = ["bi", m[2]];
+ else if (m[3] != null) [mark, content] = ["b", m[3]];
else if (m[4] != null) [mark, content] = ["i", m[4]];
- else [mark, content] = ["s", m[5]];
+ else if (m[5] != null) [mark, content] = ["i", m[5]];
+ else [mark, content] = ["s", m[6]];
out.push({ text: content, mark });
last = m.index + m[0].length;
}
@@ -94,7 +96,7 @@ function formatSegment(text, out) {
// inline formatting:
// { text } → plain text
// { text, href } → a link (http(s):// only) — never formatted inside
-// { text, mark } → formatted run; mark ∈ b | i | s | code
+// { text, mark } → formatted run; mark ∈ b | i | bi | s | code
// Links take precedence (we don't format inside a URL). Pure + flat (no nesting)
// so shell.js builds DOM with text nodes + a single wrapper element each — no
// innerHTML, so it stays XSS-safe.