Skip to content
Open
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
20 changes: 20 additions & 0 deletions Wisp/Models/Claude/ClaudeModel.swift
Original file line number Diff line number Diff line change
@@ -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]"
Expand Down
2 changes: 2 additions & 0 deletions Wisp/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
81 changes: 61 additions & 20 deletions Wisp/Views/SpriteDetail/Chat/ChatStatusBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}
}
}
Expand All @@ -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))
Expand All @@ -111,42 +137,56 @@ 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)
}

#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),
Expand All @@ -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") {
Expand Down
10 changes: 9 additions & 1 deletion Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -199,6 +206,7 @@ private struct ChatRowView: View {
NavigationStack {
ChatSwitcherSheet(viewModel: viewModel)
.environment(SpritesAPIClient())
.environment(ChatSessionManager())
.modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true)
}
}
1 change: 1 addition & 0 deletions Wisp/Views/SpriteDetail/Chat/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
4 changes: 3 additions & 1 deletion Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions Wisp/Views/SpriteDetail/StreamingDot.swift
Original file line number Diff line number Diff line change
@@ -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()
}
8 changes: 8 additions & 0 deletions WispTests/ChatViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions WispTests/ClaudeModelTests.swift
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down
Loading