The description Text sits inside ModelInfoView's ScrollView, where Text with a lineLimit doesn't reliably compute wrapping/truncation (a documented SwiftUI/AppKit quirk). Without a fixedSize hint it hard-clipped mid-word with no ellipsis; adding one fixed that but silently broke the "More…" button below it in the same VStack. Removed the whole toggle instead of chasing further edge cases — the modal already scrolls, so a long description just means more scrolling. Separately confirmed via OpenRouter's public API that at least one model's description is truncated server-side with no fuller version available through any endpoint, so this was never going to fully solve "show the complete description" for every model regardless.
330 lines
14 KiB
Swift
330 lines
14 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
|
|
|
|
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) {
|
|
Text("Description")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.secondary)
|
|
Text(desc)
|
|
.font(.body)
|
|
.foregroundColor(.primary)
|
|
.textSelection(.enabled)
|
|
}
|
|
.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)
|
|
}
|
|
|
|
// 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"
|
|
))
|
|
}
|