diff --git a/Wisp/Models/Claude/ClaudeModel.swift b/Wisp/Models/Claude/ClaudeModel.swift index 5ad09a70..2a65fdaa 100644 --- a/Wisp/Models/Claude/ClaudeModel.swift +++ b/Wisp/Models/Claude/ClaudeModel.swift @@ -1,5 +1,25 @@ import Foundation +enum ClaudeEffortLevel: String, CaseIterable, Identifiable { + case low + case medium + case high + case max + + var id: String { rawValue } + + var displayName: String { + switch self { + case .low: "Low" + case .medium: "Medium" + case .high: "High" + case .max: "Max" + } + } + + var isDefault: Bool { self == .medium } +} + enum ClaudeModel: String, CaseIterable, Identifiable { case sonnet = "sonnet[1m]" case opus = "opus[1m]" diff --git a/Wisp/ViewModels/ChatViewModel.swift b/Wisp/ViewModels/ChatViewModel.swift index edaec095..7e466de3 100644 --- a/Wisp/ViewModels/ChatViewModel.swift +++ b/Wisp/ViewModels/ChatViewModel.swift @@ -49,6 +49,7 @@ final class ChatViewModel { var status: ChatStatus = .idle var modelName: String? var modelOverride: ClaudeModel? + var effortLevel: ClaudeEffortLevel = .medium var remoteSessions: [ClaudeSessionEntry] = [] var hasAnyRemoteSessions = false var isLoadingRemoteSessions = false @@ -756,6 +757,7 @@ final class ChatViewModel { let modelId = modelOverride?.rawValue ?? UserDefaults.standard.string(forKey: "claudeModel") ?? ClaudeModel.sonnet.rawValue claudeCmd += " --model \(modelId)" + claudeCmd += " --effort \(effortLevel.rawValue)" let maxTurns = UserDefaults.standard.integer(forKey: "maxTurns") if maxTurns > 0 { diff --git a/Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift b/Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift index e8f62187..a10fedf9 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift @@ -4,6 +4,7 @@ struct ChatStatusBar: View { let status: ChatStatus let modelName: String? @Binding var modelOverride: ClaudeModel? + @Binding var effortLevel: ClaudeEffortLevel var hasPendingWispAsk: Bool = false @AppStorage("claudeModel") private var globalModel: String = ClaudeModel.sonnet.rawValue @@ -71,18 +72,37 @@ struct ChatStatusBar: View { private var modelPicker: some View { Menu { - ForEach(ClaudeModel.allCases) { model in - Button { - if model.rawValue == globalModel { - modelOverride = nil - } else { - modelOverride = model + Section("Model") { + ForEach(ClaudeModel.allCases) { model in + Button { + if model.rawValue == globalModel { + modelOverride = nil + } else { + modelOverride = model + } + if model != .opus && effortLevel == .max { + effortLevel = .high + } + } label: { + HStack { + Text(model.displayName) + if model == effectiveModel { + Image(systemName: "checkmark") + } + } } - } label: { - HStack { - Text(model.displayName) - if model == effectiveModel { - Image(systemName: "checkmark") + } + } + Section("Effort") { + ForEach(ClaudeEffortLevel.allCases.filter { $0 != .max || effectiveModel == .opus }) { level in + Button { + effortLevel = level + } label: { + HStack { + Text(level.displayName) + if level == effortLevel { + Image(systemName: "checkmark") + } } } } @@ -92,9 +112,15 @@ struct ChatStatusBar: View { Image(systemName: "sparkle") .foregroundStyle(.primary) .font(.system(size: 9)) - Text(effectiveModel.displayName) - .font(.caption2) - .foregroundStyle(.primary) + if effortLevel.isDefault { + Text(effectiveModel.displayName) + .font(.caption2) + .foregroundStyle(.primary) + } else { + Text("\(effectiveModel.displayName) · \(effortLevel.displayName)") + .font(.caption2) + .foregroundStyle(.primary) + } Image(systemName: "chevron.up.chevron.down") .foregroundStyle(.secondary) .font(.system(size: 8)) @@ -111,35 +137,48 @@ private let previewBackground = LinearGradient( #Preview("Idle - Model Picker") { @Previewable @State var modelOverride: ClaudeModel? = nil - ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929", modelOverride: $modelOverride) + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium + ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929", modelOverride: $modelOverride, effortLevel: $effortLevel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(previewBackground) +} + +#Preview("Idle - High Effort") { + @Previewable @State var modelOverride: ClaudeModel? = nil + @Previewable @State var effortLevel: ClaudeEffortLevel = .high + ChatStatusBar(status: .idle, modelName: "claude-sonnet-4-5-20250929", modelOverride: $modelOverride, effortLevel: $effortLevel) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(previewBackground) } #Preview("Streaming") { @Previewable @State var modelOverride: ClaudeModel? = nil - ChatStatusBar(status: .streaming, modelName: nil, modelOverride: $modelOverride) + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium + ChatStatusBar(status: .streaming, modelName: nil, modelOverride: $modelOverride, effortLevel: $effortLevel) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(previewBackground) } #Preview("Connecting") { @Previewable @State var modelOverride: ClaudeModel? = nil - ChatStatusBar(status: .connecting, modelName: nil, modelOverride: $modelOverride) + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium + ChatStatusBar(status: .connecting, modelName: nil, modelOverride: $modelOverride, effortLevel: $effortLevel) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(previewBackground) } #Preview("Reconnecting") { @Previewable @State var modelOverride: ClaudeModel? = nil - ChatStatusBar(status: .reconnecting, modelName: nil, modelOverride: $modelOverride) + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium + ChatStatusBar(status: .reconnecting, modelName: nil, modelOverride: $modelOverride, effortLevel: $effortLevel) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(previewBackground) } #Preview("Error") { @Previewable @State var modelOverride: ClaudeModel? = nil - ChatStatusBar(status: .error("Connection lost"), modelName: nil, modelOverride: $modelOverride) + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium + ChatStatusBar(status: .error("Connection lost"), modelName: nil, modelOverride: $modelOverride, effortLevel: $effortLevel) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(previewBackground) } @@ -147,6 +186,7 @@ private let previewBackground = LinearGradient( #Preview("All States") { @Previewable @State var stateIndex = 0 @Previewable @State var modelOverride: ClaudeModel? = nil + @Previewable @State var effortLevel: ClaudeEffortLevel = .medium let states: [(ChatStatus, String?)] = [ (.connecting, nil), @@ -160,7 +200,8 @@ private let previewBackground = LinearGradient( ChatStatusBar( status: states[stateIndex].0, modelName: states[stateIndex].1, - modelOverride: $modelOverride + modelOverride: $modelOverride, + effortLevel: $effortLevel ) Button("Next State") { diff --git a/Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift b/Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift index 41645c8f..45423c01 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift @@ -149,10 +149,17 @@ struct ChatSwitcherSheet: View { private struct ChatRowView: View { let chat: SpriteChat let isActive: Bool + @Environment(ChatSessionManager.self) private var chatSessionManager + + private var isStreaming: Bool { + chatSessionManager.isStreaming(chatId: chat.id) + } var body: some View { HStack { - if chat.isUnread { + if isStreaming { + StreamingDot() + } else if chat.isUnread { Circle() .fill(Color.accentColor) .frame(width: 8, height: 8) @@ -199,6 +206,7 @@ private struct ChatRowView: View { NavigationStack { ChatSwitcherSheet(viewModel: viewModel) .environment(SpritesAPIClient()) + .environment(ChatSessionManager()) .modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true) } } diff --git a/Wisp/Views/SpriteDetail/Chat/ChatView.swift b/Wisp/Views/SpriteDetail/Chat/ChatView.swift index 9b5c2ce3..f4d74b83 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatView.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatView.swift @@ -103,6 +103,7 @@ struct ChatView: View { status: viewModel.status, modelName: viewModel.modelName, modelOverride: Bindable(viewModel).modelOverride, + effortLevel: Bindable(viewModel).effortLevel, hasPendingWispAsk: viewModel.pendingWispAskCard != nil ) } diff --git a/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift b/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift index 36998e1a..4fa84655 100644 --- a/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift +++ b/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift @@ -84,7 +84,9 @@ struct SpriteNavigationPanel: View { } } Spacer(minLength: 0) - if chat.isUnread { + if chatSessionManager.isStreaming(chatId: chat.id) { + StreamingDot() + } else if chat.isUnread { Circle() .fill(Color.accentColor) .frame(width: 8, height: 8) diff --git a/Wisp/Views/SpriteDetail/StreamingDot.swift b/Wisp/Views/SpriteDetail/StreamingDot.swift new file mode 100644 index 00000000..28cfffe2 --- /dev/null +++ b/Wisp/Views/SpriteDetail/StreamingDot.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// A slowly pulsing orange dot shown on chat rows while Claude is actively streaming. +struct StreamingDot: View { + @State private var pulse = false + + var body: some View { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + .opacity(pulse ? 0.3 : 1.0) + .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulse) + .onAppear { pulse = true } + .onDisappear { pulse = false } + } +} + +#Preview { + HStack(spacing: 16) { + StreamingDot() + Text("Streaming…") + .foregroundStyle(.secondary) + } + .padding() +} diff --git a/WispTests/ChatViewModelTests.swift b/WispTests/ChatViewModelTests.swift index cc9eef37..c8b21f48 100644 --- a/WispTests/ChatViewModelTests.swift +++ b/WispTests/ChatViewModelTests.swift @@ -53,6 +53,14 @@ struct ChatViewModelTests { } } + // MARK: - initial state + + @Test func initialEffortLevelIsMedium() throws { + let ctx = try makeModelContext() + let (vm, _) = makeChatViewModel(modelContext: ctx) + #expect(vm.effortLevel == .medium) + } + // MARK: - handleEvent: system @Test func handleEvent_systemSetsModelName() throws { diff --git a/WispTests/ClaudeModelTests.swift b/WispTests/ClaudeModelTests.swift index 895f3055..cd196d97 100644 --- a/WispTests/ClaudeModelTests.swift +++ b/WispTests/ClaudeModelTests.swift @@ -1,6 +1,36 @@ import Testing @testable import Wisp +@Suite("ClaudeEffortLevel") +struct ClaudeEffortLevelTests { + + @Test func onlyMediumIsDefault() { + for level in ClaudeEffortLevel.allCases { + #expect(level.isDefault == (level == .medium)) + } + } + + @Test func allCasesHaveDisplayNames() { + #expect(ClaudeEffortLevel.low.displayName == "Low") + #expect(ClaudeEffortLevel.medium.displayName == "Medium") + #expect(ClaudeEffortLevel.high.displayName == "High") + #expect(ClaudeEffortLevel.max.displayName == "Max") + } + + @Test func rawValuesMatchCLIFlag() { + #expect(ClaudeEffortLevel.low.rawValue == "low") + #expect(ClaudeEffortLevel.medium.rawValue == "medium") + #expect(ClaudeEffortLevel.high.rawValue == "high") + #expect(ClaudeEffortLevel.max.rawValue == "max") + } + + @Test func identifiableUsesRawValue() { + for level in ClaudeEffortLevel.allCases { + #expect(level.id == level.rawValue) + } + } +} + @Suite("ClaudeModel") struct ClaudeModelTests {