import Foundation import Observation import SessionCore /// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the /// UserDefaults-backed palette store — a behavior mirror of /// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip / /// updateChip / loadPalette / savePalette). /// /// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs → /// UserDefaults with an injectable suite (same pattern as /// `UserDefaultsLastSessionStore`), never Keychain. /// One sendable snippet (mirror of the web `Chip` interface). struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable { /// Stable identifier (built-ins use the `__` prefix, user chips `user_`). let id: String /// Raw bytes to send (without the Enter suffix). let text: String /// Display label on the chip button. Rendered via `Text(verbatim:)` only — /// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3). let label: String /// If true, Enter (`\r`) is appended to `text` when sending. let appendEnter: Bool } /// Pure palette operations — every function returns a NEW array and never /// mutates its input (web quick-reply.ts CRUD, verbatim semantics). enum QuickReplyPalette { /// The six built-in chips for Claude Code interaction, order and values /// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap` /// — hand-writing an escape sequence in the App layer is a review finding. static let builtInChips: [QuickReplyChip] = [ QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true), QuickReplyChip(id: "__continue", text: "continue", label: "continue", appendEnter: true), QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true), QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true), QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true), QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc", appendEnter: false), ] /// Prefix for user-created chip ids (web: `user__`; iOS: UUID). static let userChipIdPrefix = "user_" /// The full byte string a chip tap sends: text plus — when `appendEnter` — /// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha). static func payload(for chip: QuickReplyChip) -> String { chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "") } /// New array with `chip` appended (web `addChip`). static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] { chips + [chip] } /// New array with the chip matching `id` removed (web `removeChip`). static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] { chips.filter { $0.id != id } } /// New array with the chip at `fromIndex` moved to `toIndex` (web /// `reorderChip` splice semantics). Either index out of bounds → the input /// returned unchanged. Operates on a local copy — value semantics /// guarantee the caller's array is never touched. static func reordering( _ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int ) -> [QuickReplyChip] { guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else { return chips } var result = chips let moved = result.remove(at: fromIndex) result.insert(moved, at: toIndex) return result } /// New array where the chip with `id` has non-nil fields patched (web /// `updateChip`: shallow merge, id immutable). Unknown id → unchanged. static func updating( _ chips: [QuickReplyChip], id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil ) -> [QuickReplyChip] { chips.map { chip in guard chip.id == id else { return chip } return QuickReplyChip( id: chip.id, text: text ?? chip.text, label: label ?? chip.label, appendEnter: appendEnter ?? chip.appendEnter ) } } } /// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`): /// one malformed entry is dropped, the well-formed rest survive — stored data /// crosses a storage boundary and is never trusted (LastSessionStore pattern). private struct LossyQuickReplyChip: Decodable { let chip: QuickReplyChip? init(from decoder: any Decoder) throws { chip = try? QuickReplyChip(from: decoder) } } /// Observable palette store: holds the user chips, persists every change to /// the injected `UserDefaults` and exposes the render list (built-ins first, /// then user chips — web render order). @MainActor @Observable final class QuickReplyStore { /// Single UserDefaults key for the encoded user palette /// (web: localStorage `web-terminal:quick-reply-palette`). static let paletteDefaultsKey = "quickReplyPalette" private(set) var userChips: [QuickReplyChip] @ObservationIgnored private let defaults: UserDefaults /// Built-in chips first, then the user palette (web `render()` order). var allChips: [QuickReplyChip] { QuickReplyPalette.builtInChips + userChips } init(defaults: UserDefaults = .standard) { self.defaults = defaults userChips = Self.loadPalette(from: defaults) } /// Add a new phrase (web editor `onSaveClick` semantics): text/label are /// trimmed, empty text is a no-op (returns false), empty label defaults to /// the text, the id is minted with the `user_` prefix. @discardableResult func addChip(text: String, label: String, appendEnter: Bool) -> Bool { let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedText.isEmpty else { return false } let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) let chip = QuickReplyChip( id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString, text: trimmedText, label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel, appendEnter: appendEnter ) setUserChips(QuickReplyPalette.adding(userChips, chip)) return true } func removeChip(id: String) { setUserChips(QuickReplyPalette.removing(userChips, id: id)) } func moveChip(fromIndex: Int, toIndex: Int) { setUserChips(QuickReplyPalette.reordering( userChips, fromIndex: fromIndex, toIndex: toIndex )) } /// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on /// the ORIGINAL array — `Array.move` implements that convention). func moveChips(fromOffsets: IndexSet, toOffset: Int) { var next = userChips next.move(fromOffsets: fromOffsets, toOffset: toOffset) setUserChips(next) } func updateChip( id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil ) { setUserChips(QuickReplyPalette.updating( userChips, id: id, text: text, label: label, appendEnter: appendEnter )) } /// Load the persisted palette. Missing key, non-JSON data or a non-array /// root → `[]`; malformed items are dropped per-entry. Never throws /// (web `loadPalette` contract). static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] { guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] } guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data) else { return [] } return lossy.compactMap(\.chip) } // MARK: - Private private func setUserChips(_ chips: [QuickReplyChip]) { userChips = chips persist(chips) } /// Best-effort save (web `savePalette` contract: storage failure never /// throws or crashes — the in-memory palette stays authoritative for the /// session). Encoding a `[QuickReplyChip]` cannot practically fail. private func persist(_ chips: [QuickReplyChip]) { guard let data = try? JSONEncoder().encode(chips) else { return } defaults.set(data, forKey: Self.paletteDefaultsKey) } }