.onExitCommand alone wasn't enough: clicking into the multi-line .textSelection(.enabled) description handed it real AppKit first-responder status, and its own cancelOperation: handling for Escape consumed the key event before it ever reached the modal's exit-command handler. Replaced text-selection on the description with an explicit Copy button (same pattern as the chat message copy button in MessageRow.swift) so it can no longer grab keyboard focus at all. infoRow's single-line values keep .textSelection(.enabled) — only the multi-line description reproduced the bug.
375 lines
16 KiB
Swift
375 lines
16 KiB
Swift
//
|
|
// ModelInfoView.swift
|
|
// Confab
|
|
//
|
|
// Rich model information modal
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import SwiftUI
|
|
|
|
struct ModelInfoView: View {
|
|
let model: ModelInfo
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
@Bindable private var settings = SettingsService.shared
|
|
@State private var showDescriptionCopied = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
Text("Model Info")
|
|
.font(.system(size: 18, weight: .bold))
|
|
Spacer()
|
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
|
Image(systemName: isFav ? "star.fill" : "star")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(isFav ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
|
.padding(.trailing, 8)
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 12)
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
// Overview
|
|
sectionHeader("Overview")
|
|
infoRow("Name", model.name)
|
|
infoRow("ID", model.id)
|
|
if let provider = model.topProvider {
|
|
infoRow("Provider", provider)
|
|
}
|
|
if let releaseDate = model.releaseDate {
|
|
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
|
|
}
|
|
if let desc = model.description {
|
|
// Always shown in full, no truncate/expand toggle — Text with a lineLimit
|
|
// nested inside this view's ScrollView doesn't reliably compute wrapping/
|
|
// truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize
|
|
// hint it hard-clips mid-word with no ellipsis; with one, sibling views in
|
|
// the same VStack — like the former "More…" button — can silently fail to
|
|
// lay out). The modal itself already scrolls, so a long description just
|
|
// means more scrolling, which sidesteps the whole bug class.
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text("Description")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
// A Copy button instead of .textSelection(.enabled): clicking into
|
|
// a multi-line selectable Text hands it real AppKit first-responder
|
|
// status, and Escape then gets consumed by that text view's own
|
|
// cancelOperation: handling before it ever reaches this modal's
|
|
// onExitCommand — the beep-instead-of-dismiss bug Rune reported.
|
|
// infoRow's single-line values keep .textSelection(.enabled); only
|
|
// this multi-line block reproduced the bug.
|
|
Button(action: copyDescription) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc")
|
|
.font(.system(size: 11))
|
|
if showDescriptionCopied {
|
|
Text("Copied!")
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
.foregroundColor(showDescriptionCopied ? .green : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Text(desc)
|
|
.font(.body)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Pricing
|
|
sectionHeader("Pricing")
|
|
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
|
|
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
|
|
|
|
if model.pricing.prompt > 0 {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Cost Examples")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
HStack(spacing: 16) {
|
|
costExample(label: "1K tokens", inputTokens: 1_000)
|
|
costExample(label: "10K tokens", inputTokens: 10_000)
|
|
costExample(label: "100K tokens", inputTokens: 100_000)
|
|
}
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Context Window
|
|
sectionHeader("Context Window")
|
|
infoRow("Max Tokens", model.contextLength.formatted())
|
|
|
|
if model.contextLength > 0 {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
let maxContext = 2_000_000.0
|
|
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.gray.opacity(0.2))
|
|
.frame(height: 16)
|
|
GeometryReader { geo in
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.blue)
|
|
.frame(width: geo.size.width * fraction, height: 16)
|
|
}
|
|
.frame(height: 16)
|
|
}
|
|
Text(model.contextLengthDisplay + " tokens")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Capabilities
|
|
sectionHeader("Capabilities")
|
|
HStack(spacing: 12) {
|
|
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
|
|
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
|
|
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
|
|
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
|
|
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
|
}
|
|
|
|
// Categories (if any)
|
|
if !model.categories.isEmpty {
|
|
Divider()
|
|
sectionHeader("Categories")
|
|
FlowLayout(spacing: 8) {
|
|
ForEach(model.categories, id: \.rawValue) { cat in
|
|
HStack(spacing: 5) {
|
|
Image(systemName: cat.systemImage)
|
|
.font(.caption2)
|
|
Text(LocalizedStringKey(cat.rawValue))
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(cat.color.opacity(0.12))
|
|
.foregroundColor(cat.color)
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
// Architecture (if available)
|
|
if let arch = model.architecture {
|
|
Divider()
|
|
sectionHeader("Architecture")
|
|
if let modality = arch.modality {
|
|
infoRow("Modality", modality)
|
|
}
|
|
if let tokenizer = arch.tokenizer {
|
|
infoRow("Tokenizer", tokenizer)
|
|
}
|
|
if let instructType = arch.instructType {
|
|
infoRow("Instruct Type", instructType)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Bottom bar
|
|
HStack {
|
|
Spacer()
|
|
Button("Done") { dismiss() }
|
|
.keyboardShortcut(.return, modifiers: [])
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.regular)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
|
|
// Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text,
|
|
// the description). Once one of those has text-selection focus, Escape can get
|
|
// intercepted by AppKit's text-selection machinery instead of reaching the close
|
|
// button's .keyboardShortcut(.escape) — no action is bound there, so it just beeps
|
|
// instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel"
|
|
// user command and fires regardless of which child currently holds focus, so it's a more
|
|
// reliable place to handle this than a single button's keyboardShortcut alone.
|
|
.onExitCommand { dismiss() }
|
|
}
|
|
|
|
private func copyDescription() {
|
|
guard let desc = model.description else { return }
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(desc, forType: .string)
|
|
withAnimation {
|
|
showDescriptionCopied = true
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation {
|
|
showDescriptionCopied = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Helpers
|
|
|
|
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
|
|
Text(title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.body)
|
|
Spacer()
|
|
Text(value)
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func costExample(label: LocalizedStringKey, inputTokens: Int) -> some View {
|
|
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
|
|
(Double(inputTokens) * model.pricing.completion / 1_000_000)
|
|
VStack(spacing: 2) {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
Text(String(format: "$%.4f", cost))
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundColor(.primary)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.gray.opacity(0.1))
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.foregroundColor(active ? .blue : .gray.opacity(0.4))
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundColor(active ? .primary : .secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow Layout
|
|
|
|
struct FlowLayout: Layout {
|
|
var spacing: CGFloat = 8
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
|
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
|
|
let height = rows.map { row in
|
|
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
|
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
|
|
return CGSize(width: proposal.width ?? 0, height: height)
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
|
let rows = rows(for: subviews, width: bounds.width)
|
|
var y = bounds.minY
|
|
for row in rows {
|
|
var x = bounds.minX
|
|
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
|
for sub in row {
|
|
let size = sub.sizeThatFits(.unspecified)
|
|
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
x += size.width + spacing
|
|
}
|
|
y += rowH + spacing
|
|
}
|
|
}
|
|
|
|
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
|
|
var rows: [[LayoutSubview]] = [[]]
|
|
var rowWidth: CGFloat = 0
|
|
for sub in subviews {
|
|
let w = sub.sizeThatFits(.unspecified).width
|
|
if rowWidth + w > width, !rows.last!.isEmpty {
|
|
rows.append([])
|
|
rowWidth = 0
|
|
}
|
|
rows[rows.count - 1].append(sub)
|
|
rowWidth += w + spacing
|
|
}
|
|
return rows
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ModelInfoView(model: ModelInfo(
|
|
id: "anthropic/claude-sonnet-4",
|
|
name: "Claude Sonnet 4",
|
|
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
|
|
contextLength: 200_000,
|
|
pricing: .init(prompt: 3.0, completion: 15.0),
|
|
capabilities: .init(vision: true, tools: true, online: false),
|
|
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
|
|
topProvider: "anthropic"
|
|
))
|
|
}
|