Skip to content

Commit ba57035

Browse files
zmanianclaude
andcommitted
Add per-chat model selector and use 1M context models
Replace the static model name display in the status bar with an interactive model picker when idle. Tapping shows a menu to switch between Sonnet, Opus, and Haiku for the current chat session. - ChatStatusBar: idle state shows a Menu-based model picker with sparkle icon and chevron indicator - ChatViewModel: add modelOverride property that takes precedence over the global default from Settings - ClaudeModel: update Sonnet and Opus raw values to 1M context variants (sonnet[1m], opus[1m]) - Keep Sonnet as the global default per maintainer feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a42bb5d commit ba57035

File tree

5 files changed

+99
-66
lines changed

5 files changed

+99
-66
lines changed

Wisp/Models/Claude/ClaudeModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import Foundation
22

33
enum ClaudeModel: String, CaseIterable, Identifiable {
4-
case sonnet
5-
case opus
4+
case sonnet = "sonnet[1m]"
5+
case opus = "opus[1m]"
66
case haiku
77

88
var id: String { rawValue }

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ final class ChatViewModel {
4848
var inputText = ""
4949
var status: ChatStatus = .idle
5050
var modelName: String?
51+
var modelOverride: ClaudeModel?
5152
var remoteSessions: [ClaudeSessionEntry] = []
5253
var hasAnyRemoteSessions = false
5354
var isLoadingRemoteSessions = false
@@ -750,7 +751,7 @@ final class ChatViewModel {
750751
claudeCmd += " --mcp-config \(configPath)"
751752
}
752753

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

756757
let maxTurns = UserDefaults.standard.integer(forKey: "maxTurns")

Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift

Lines changed: 90 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,79 +3,103 @@ import SwiftUI
33
struct ChatStatusBar: View {
44
let status: ChatStatus
55
let modelName: String?
6+
@Binding var modelOverride: ClaudeModel?
67
var hasPendingWispAsk: Bool = false
78

9+
@AppStorage("claudeModel") private var globalModel: String = ClaudeModel.sonnet.rawValue
10+
11+
private var effectiveModel: ClaudeModel {
12+
modelOverride ?? ClaudeModel(rawValue: globalModel) ?? .sonnet
13+
}
14+
815
private var statusKey: String {
916
switch status {
10-
case .idle: return "idle-\(modelName ?? "")"
17+
case .idle: return "idle-\(modelName ?? "")-\(effectiveModel.rawValue)"
1118
case .connecting: return "connecting"
1219
case .streaming: return hasPendingWispAsk ? "waiting" : "streaming"
1320
case .reconnecting: return "reconnecting"
1421
case .error(let message): return "error-\(message)"
1522
}
1623
}
1724

18-
private var isVisible: Bool {
19-
switch status {
20-
case .idle: return modelName != nil
21-
case .connecting, .streaming, .reconnecting, .error: return true
25+
var body: some View {
26+
HStack {
27+
statusPill
28+
Spacer()
2229
}
30+
.padding(.horizontal)
2331
}
2432

25-
var body: some View {
26-
if isVisible {
27-
HStack {
28-
statusPill
29-
Spacer()
33+
private var statusPill: some View {
34+
HStack(spacing: 6) {
35+
switch status {
36+
case .idle:
37+
modelPicker
38+
case .connecting:
39+
ProgressView()
40+
.controlSize(.mini)
41+
Text("Connecting...")
42+
.font(.caption2)
43+
.foregroundStyle(.secondary)
44+
case .streaming:
45+
ProgressView()
46+
.controlSize(.mini)
47+
Text(hasPendingWispAsk ? "Waiting for answer..." : "Streaming...")
48+
.font(.caption2)
49+
.foregroundStyle(.secondary)
50+
case .reconnecting:
51+
ProgressView()
52+
.controlSize(.mini)
53+
Text("Reconnecting...")
54+
.font(.caption2)
55+
.foregroundStyle(.orange)
56+
case .error(let message):
57+
Image(systemName: "exclamationmark.triangle.fill")
58+
.foregroundStyle(.red)
59+
.font(.system(size: 10))
60+
Text(message)
61+
.font(.caption2)
62+
.foregroundStyle(.red)
63+
.lineLimit(1)
3064
}
31-
.padding(.horizontal)
3265
}
66+
.padding(.horizontal, 10)
67+
.padding(.vertical, 4)
68+
.glassEffect()
69+
.animation(.easeInOut(duration: 0.2), value: statusKey)
3370
}
3471

35-
private var statusPill: some View {
36-
HStack(spacing: 6) {
37-
switch status {
38-
case .idle:
39-
if let modelName {
40-
Image(systemName: "checkmark.circle.fill")
41-
.foregroundStyle(.green)
42-
.font(.system(size: 10))
43-
Text(modelName)
44-
.font(.caption2)
45-
.foregroundStyle(.secondary)
72+
private var modelPicker: some View {
73+
Menu {
74+
ForEach(ClaudeModel.allCases) { model in
75+
Button {
76+
if model.rawValue == globalModel {
77+
modelOverride = nil
78+
} else {
79+
modelOverride = model
80+
}
81+
} label: {
82+
HStack {
83+
Text(model.displayName)
84+
if model == effectiveModel {
85+
Image(systemName: "checkmark")
86+
}
4687
}
47-
case .connecting:
48-
ProgressView()
49-
.controlSize(.mini)
50-
Text("Connecting...")
51-
.font(.caption2)
52-
.foregroundStyle(.secondary)
53-
case .streaming:
54-
ProgressView()
55-
.controlSize(.mini)
56-
Text(hasPendingWispAsk ? "Waiting for answer..." : "Streaming...")
57-
.font(.caption2)
58-
.foregroundStyle(.secondary)
59-
case .reconnecting:
60-
ProgressView()
61-
.controlSize(.mini)
62-
Text("Reconnecting...")
63-
.font(.caption2)
64-
.foregroundStyle(.orange)
65-
case .error(let message):
66-
Image(systemName: "exclamationmark.triangle.fill")
67-
.foregroundStyle(.red)
68-
.font(.system(size: 10))
69-
Text(message)
70-
.font(.caption2)
71-
.foregroundStyle(.red)
72-
.lineLimit(1)
7388
}
7489
}
75-
.padding(.horizontal, 10)
76-
.padding(.vertical, 4)
77-
.glassEffect()
78-
.animation(.easeInOut(duration: 0.2), value: statusKey)
90+
} label: {
91+
HStack(spacing: 4) {
92+
Image(systemName: "sparkle")
93+
.foregroundStyle(.secondary)
94+
.font(.system(size: 9))
95+
Text(effectiveModel.displayName)
96+
.font(.caption2)
97+
.foregroundStyle(.secondary)
98+
Image(systemName: "chevron.up.chevron.down")
99+
.foregroundStyle(.tertiary)
100+
.font(.system(size: 8))
101+
}
102+
}
79103
}
80104
}
81105

@@ -85,38 +109,44 @@ private let previewBackground = LinearGradient(
85109
endPoint: .bottomTrailing
86110
)
87111

88-
#Preview("Idle") {
89-
ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929")
112+
#Preview("Idle - Model Picker") {
113+
@Previewable @State var modelOverride: ClaudeModel? = nil
114+
ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929", modelOverride: $modelOverride)
90115
.frame(maxWidth: .infinity, maxHeight: .infinity)
91116
.background(previewBackground)
92117
}
93118

94119
#Preview("Streaming") {
95-
ChatStatusBar(status: .streaming, modelName: nil)
120+
@Previewable @State var modelOverride: ClaudeModel? = nil
121+
ChatStatusBar(status: .streaming, modelName: nil, modelOverride: $modelOverride)
96122
.frame(maxWidth: .infinity, maxHeight: .infinity)
97123
.background(previewBackground)
98124
}
99125

100126
#Preview("Connecting") {
101-
ChatStatusBar(status: .connecting, modelName: nil)
127+
@Previewable @State var modelOverride: ClaudeModel? = nil
128+
ChatStatusBar(status: .connecting, modelName: nil, modelOverride: $modelOverride)
102129
.frame(maxWidth: .infinity, maxHeight: .infinity)
103130
.background(previewBackground)
104131
}
105132

106133
#Preview("Reconnecting") {
107-
ChatStatusBar(status: .reconnecting, modelName: nil)
134+
@Previewable @State var modelOverride: ClaudeModel? = nil
135+
ChatStatusBar(status: .reconnecting, modelName: nil, modelOverride: $modelOverride)
108136
.frame(maxWidth: .infinity, maxHeight: .infinity)
109137
.background(previewBackground)
110138
}
111139

112140
#Preview("Error") {
113-
ChatStatusBar(status: .error("Connection lost"), modelName: nil)
141+
@Previewable @State var modelOverride: ClaudeModel? = nil
142+
ChatStatusBar(status: .error("Connection lost"), modelName: nil, modelOverride: $modelOverride)
114143
.frame(maxWidth: .infinity, maxHeight: .infinity)
115144
.background(previewBackground)
116145
}
117146

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

121151
let states: [(ChatStatus, String?)] = [
122152
(.connecting, nil),
@@ -129,7 +159,8 @@ private let previewBackground = LinearGradient(
129159
VStack(spacing: 20) {
130160
ChatStatusBar(
131161
status: states[stateIndex].0,
132-
modelName: states[stateIndex].1
162+
modelName: states[stateIndex].1,
163+
modelOverride: $modelOverride
133164
)
134165

135166
Button("Next State") {

Wisp/Views/SpriteDetail/Chat/ChatView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ struct ChatView: View {
100100
ChatStatusBar(
101101
status: viewModel.status,
102102
modelName: viewModel.modelName,
103+
modelOverride: Bindable(viewModel).modelOverride,
103104
hasPendingWispAsk: viewModel.pendingWispAskCard != nil
104105
)
105106
}

WispTests/ClaudeModelTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ struct ClaudeModelTests {
1010
#expect(ClaudeModel.haiku.displayName == "Haiku")
1111
}
1212

13-
@Test func rawValuesAreAliases() {
14-
#expect(ClaudeModel.sonnet.rawValue == "sonnet")
15-
#expect(ClaudeModel.opus.rawValue == "opus")
13+
@Test func rawValuesAre1MContextAliases() {
14+
#expect(ClaudeModel.sonnet.rawValue == "sonnet[1m]")
15+
#expect(ClaudeModel.opus.rawValue == "opus[1m]")
1616
#expect(ClaudeModel.haiku.rawValue == "haiku")
1717
}
1818

@@ -23,7 +23,7 @@ struct ClaudeModelTests {
2323
}
2424

2525
@Test func initFromRawValue() {
26-
#expect(ClaudeModel(rawValue: "opus") == .opus)
26+
#expect(ClaudeModel(rawValue: "opus[1m]") == .opus)
2727
#expect(ClaudeModel(rawValue: "invalid") == nil)
2828
}
2929
}

0 commit comments

Comments
 (0)