Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Wisp/Models/Claude/ClaudeModel.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Foundation

enum ClaudeModel: String, CaseIterable, Identifiable {
case sonnet
case opus
case sonnet = "sonnet[1m]"
case opus = "opus[1m]"
case haiku

var id: String { rawValue }
Expand Down
3 changes: 2 additions & 1 deletion Wisp/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class ChatViewModel {
var inputText = ""
var status: ChatStatus = .idle
var modelName: String?
var modelOverride: ClaudeModel?
var remoteSessions: [ClaudeSessionEntry] = []
var hasAnyRemoteSessions = false
var isLoadingRemoteSessions = false
Expand Down Expand Up @@ -750,7 +751,7 @@ final class ChatViewModel {
claudeCmd += " --mcp-config \(configPath)"
}

let modelId = UserDefaults.standard.string(forKey: "claudeModel") ?? ClaudeModel.sonnet.rawValue
let modelId = modelOverride?.rawValue ?? UserDefaults.standard.string(forKey: "claudeModel") ?? ClaudeModel.sonnet.rawValue
claudeCmd += " --model \(modelId)"

let maxTurns = UserDefaults.standard.integer(forKey: "maxTurns")
Expand Down
149 changes: 90 additions & 59 deletions Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,103 @@ import SwiftUI
struct ChatStatusBar: View {
let status: ChatStatus
let modelName: String?
@Binding var modelOverride: ClaudeModel?
var hasPendingWispAsk: Bool = false

@AppStorage("claudeModel") private var globalModel: String = ClaudeModel.sonnet.rawValue

private var effectiveModel: ClaudeModel {
modelOverride ?? ClaudeModel(rawValue: globalModel) ?? .sonnet
}

private var statusKey: String {
switch status {
case .idle: return "idle-\(modelName ?? "")"
case .idle: return "idle-\(modelName ?? "")-\(effectiveModel.rawValue)"
case .connecting: return "connecting"
case .streaming: return hasPendingWispAsk ? "waiting" : "streaming"
case .reconnecting: return "reconnecting"
case .error(let message): return "error-\(message)"
}
}

private var isVisible: Bool {
switch status {
case .idle: return modelName != nil
case .connecting, .streaming, .reconnecting, .error: return true
var body: some View {
HStack {
statusPill
Spacer()
}
.padding(.horizontal)
}

var body: some View {
if isVisible {
HStack {
statusPill
Spacer()
private var statusPill: some View {
HStack(spacing: 6) {
switch status {
case .idle:
modelPicker
case .connecting:
ProgressView()
.controlSize(.mini)
Text("Connecting...")
.font(.caption2)
.foregroundStyle(.secondary)
case .streaming:
ProgressView()
.controlSize(.mini)
Text(hasPendingWispAsk ? "Waiting for answer..." : "Streaming...")
.font(.caption2)
.foregroundStyle(.secondary)
case .reconnecting:
ProgressView()
.controlSize(.mini)
Text("Reconnecting...")
.font(.caption2)
.foregroundStyle(.orange)
case .error(let message):
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.system(size: 10))
Text(message)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
.padding(.horizontal)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.glassEffect()
.animation(.easeInOut(duration: 0.2), value: statusKey)
}

private var statusPill: some View {
HStack(spacing: 6) {
switch status {
case .idle:
if let modelName {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 10))
Text(modelName)
.font(.caption2)
.foregroundStyle(.secondary)
private var modelPicker: some View {
Menu {
ForEach(ClaudeModel.allCases) { model in
Button {
if model.rawValue == globalModel {
modelOverride = nil
} else {
modelOverride = model
}
} label: {
HStack {
Text(model.displayName)
if model == effectiveModel {
Image(systemName: "checkmark")
}
}
case .connecting:
ProgressView()
.controlSize(.mini)
Text("Connecting...")
.font(.caption2)
.foregroundStyle(.secondary)
case .streaming:
ProgressView()
.controlSize(.mini)
Text(hasPendingWispAsk ? "Waiting for answer..." : "Streaming...")
.font(.caption2)
.foregroundStyle(.secondary)
case .reconnecting:
ProgressView()
.controlSize(.mini)
Text("Reconnecting...")
.font(.caption2)
.foregroundStyle(.orange)
case .error(let message):
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.system(size: 10))
Text(message)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.glassEffect()
.animation(.easeInOut(duration: 0.2), value: statusKey)
} label: {
HStack(spacing: 4) {
Image(systemName: "sparkle")
.foregroundStyle(.primary)
.font(.system(size: 9))
Text(effectiveModel.displayName)
.font(.caption2)
.foregroundStyle(.primary)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
.font(.system(size: 8))
}
}
}
}

Expand All @@ -85,38 +109,44 @@ private let previewBackground = LinearGradient(
endPoint: .bottomTrailing
)

#Preview("Idle") {
ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929")
#Preview("Idle - Model Picker") {
@Previewable @State var modelOverride: ClaudeModel? = nil
ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929", modelOverride: $modelOverride)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(previewBackground)
}

#Preview("Streaming") {
ChatStatusBar(status: .streaming, modelName: nil)
@Previewable @State var modelOverride: ClaudeModel? = nil
ChatStatusBar(status: .streaming, modelName: nil, modelOverride: $modelOverride)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(previewBackground)
}

#Preview("Connecting") {
ChatStatusBar(status: .connecting, modelName: nil)
@Previewable @State var modelOverride: ClaudeModel? = nil
ChatStatusBar(status: .connecting, modelName: nil, modelOverride: $modelOverride)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(previewBackground)
}

#Preview("Reconnecting") {
ChatStatusBar(status: .reconnecting, modelName: nil)
@Previewable @State var modelOverride: ClaudeModel? = nil
ChatStatusBar(status: .reconnecting, modelName: nil, modelOverride: $modelOverride)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(previewBackground)
}

#Preview("Error") {
ChatStatusBar(status: .error("Connection lost"), modelName: nil)
@Previewable @State var modelOverride: ClaudeModel? = nil
ChatStatusBar(status: .error("Connection lost"), modelName: nil, modelOverride: $modelOverride)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(previewBackground)
}

#Preview("All States") {
@Previewable @State var stateIndex = 0
@Previewable @State var modelOverride: ClaudeModel? = nil

let states: [(ChatStatus, String?)] = [
(.connecting, nil),
Expand All @@ -129,7 +159,8 @@ private let previewBackground = LinearGradient(
VStack(spacing: 20) {
ChatStatusBar(
status: states[stateIndex].0,
modelName: states[stateIndex].1
modelName: states[stateIndex].1,
modelOverride: $modelOverride
)

Button("Next State") {
Expand Down
1 change: 1 addition & 0 deletions Wisp/Views/SpriteDetail/Chat/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ struct ChatView: View {
ChatStatusBar(
status: viewModel.status,
modelName: viewModel.modelName,
modelOverride: Bindable(viewModel).modelOverride,
hasPendingWispAsk: viewModel.pendingWispAskCard != nil
)
}
Expand Down
8 changes: 4 additions & 4 deletions WispTests/ClaudeModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ struct ClaudeModelTests {
#expect(ClaudeModel.haiku.displayName == "Haiku")
}

@Test func rawValuesAreAliases() {
#expect(ClaudeModel.sonnet.rawValue == "sonnet")
#expect(ClaudeModel.opus.rawValue == "opus")
@Test func rawValuesAre1MContextAliases() {
#expect(ClaudeModel.sonnet.rawValue == "sonnet[1m]")
#expect(ClaudeModel.opus.rawValue == "opus[1m]")
#expect(ClaudeModel.haiku.rawValue == "haiku")
}

Expand All @@ -23,7 +23,7 @@ struct ClaudeModelTests {
}

@Test func initFromRawValue() {
#expect(ClaudeModel(rawValue: "opus") == .opus)
#expect(ClaudeModel(rawValue: "opus[1m]") == .opus)
#expect(ClaudeModel(rawValue: "invalid") == nil)
}
}
Loading