on main help sign in
Merge branch 'main' into ui
05049e7Andrey Nering
go.mod+1−1
@@ -18,7 +18,7 @@ require (
18 github.com/aymanbagabas/go-udiff v0.3.1
18 github.com/aymanbagabas/go-udiff v0.3.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
20 github.com/charlievieth/fastwalk v1.0.14
20 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.12.0
21+ github.com/charmbracelet/catwalk v0.12.2
22 github.com/charmbracelet/colorprofile v0.4.1
22 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/fang v0.4.4
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
go.sum+2−2
@@ -94,8 +94,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
94github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
94github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
95github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
95github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
96github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
96github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
97−github.com/charmbracelet/catwalk v0.12.0 h1:CCxbZpgMPyZNtnaRGvL//BgPkvOWOYVFhRf925Dfrdg=
97+github.com/charmbracelet/catwalk v0.12.2 h1:zq9b+7kiumof/Dzvqi/oHnwMBgSN/M2Yt82vlIAiKMU=
98−github.com/charmbracelet/catwalk v0.12.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
98+github.com/charmbracelet/catwalk v0.12.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
99github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
99github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
100github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
100github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
101github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
101github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/agent.go−18
@@ -833,10 +833,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
833 modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
833 modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
834 modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
834 modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
835
835
836− if a.isClaudeCode() {
837− cost = 0
838− }
839−
840 // Use override cost if available (e.g., from OpenRouter).
836 // Use override cost if available (e.g., from OpenRouter).
841 if openrouterCost != nil {
837 if openrouterCost != nil {
842 cost = *openrouterCost
838 cost = *openrouterCost
@@ -874,10 +870,6 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
874 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
870 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
875 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
871 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
876
872
877− if a.isClaudeCode() {
878− cost = 0
879− }
880−
881 a.eventTokensUsed(session.ID, model, usage, cost)
873 a.eventTokensUsed(session.ID, model, usage, cost)
882
874
883 if overrideCost != nil {
875 if overrideCost != nil {
@@ -985,19 +977,9 @@ func (a *sessionAgent) Model() Model {
985}
977}
986
978
987func (a *sessionAgent) promptPrefix() string {
979func (a *sessionAgent) promptPrefix() string {
988− if a.isClaudeCode() {
989− return "You are Claude Code, Anthropic's official CLI for Claude."
990− }
991 return a.systemPromptPrefix
980 return a.systemPromptPrefix
992}
981}
993
982
994−// XXX: this should be generalized to cover other subscription plans, like Copilot.
995−func (a *sessionAgent) isClaudeCode() bool {
996− cfg := config.Get()
997− pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
998− return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
999−}
1000−
1001// convertToToolResult converts a fantasy tool result to a message tool result.
983// convertToToolResult converts a fantasy tool result to a message tool result.
1002func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
984func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
1003 baseResult := message.ToolResult{
985 baseResult := message.ToolResult{
internal/agent/coordinator.go+4−10
@@ -407,12 +407,6 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
407 }
407 }
408
408
409 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
409 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
410− // Check MCP-specific disabled tools.
411− if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
412− if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
413− continue
414− }
415− }
416 if agent.AllowedMCP == nil {
410 if agent.AllowedMCP == nil {
417 // No MCP restrictions
411 // No MCP restrictions
418 filteredTools = append(filteredTools, tool)
412 filteredTools = append(filteredTools, tool)
@@ -524,13 +518,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
524 }, nil
518 }, nil
525}
519}
526
520
527−func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
521+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
528 var opts []anthropic.Option
522 var opts []anthropic.Option
529
523
530− if isOauth {
524+ if strings.HasPrefix(apiKey, "Bearer ") {
531 // NOTE: Prevent the SDK from picking up the API key from env.
525 // NOTE: Prevent the SDK from picking up the API key from env.
532 os.Setenv("ANTHROPIC_API_KEY", "")
526 os.Setenv("ANTHROPIC_API_KEY", "")
533− headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
527+ headers["Authorization"] = apiKey
534 } else if apiKey != "" {
528 } else if apiKey != "" {
535 // X-Api-Key header
529 // X-Api-Key header
536 opts = append(opts, anthropic.WithAPIKey(apiKey))
530 opts = append(opts, anthropic.WithAPIKey(apiKey))
@@ -737,7 +731,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
737 case openai.Name:
731 case openai.Name:
738 return c.buildOpenaiProvider(baseURL, apiKey, headers)
732 return c.buildOpenaiProvider(baseURL, apiKey, headers)
739 case anthropic.Name:
733 case anthropic.Name:
740− return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
734+ return c.buildAnthropicProvider(baseURL, apiKey, headers)
741 case openrouter.Name:
735 case openrouter.Name:
742 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
736 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
743 case azure.Name:
737 case azure.Name:
internal/agent/tools/edit.go+11−10
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
16 "github.com/charmbracelet/crush/internal/filepathext"
17+ "github.com/charmbracelet/crush/internal/filetracker"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/history"
19
20
@@ -159,8 +160,8 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
159 slog.Error("Error creating file history version", "error", err)
160 slog.Error("Error creating file history version", "error", err)
160 }
161 }
161
162
162− recordFileWrite(filePath)
163+ filetracker.RecordWrite(filePath)
163− recordFileRead(filePath)
164+ filetracker.RecordRead(filePath)
164
165
165 return fantasy.WithResponseMetadata(
166 return fantasy.WithResponseMetadata(
166 fantasy.NewTextResponse("File created: "+filePath),
167 fantasy.NewTextResponse("File created: "+filePath),
@@ -186,12 +187,12 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
186 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
187 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
187 }
188 }
188
189
189− if getLastReadTime(filePath).IsZero() {
190+ if filetracker.LastReadTime(filePath).IsZero() {
190 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
191 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
191 }
192 }
192
193
193 modTime := fileInfo.ModTime()
194 modTime := fileInfo.ModTime()
194− lastRead := getLastReadTime(filePath)
195+ lastRead := filetracker.LastReadTime(filePath)
195 if modTime.After(lastRead) {
196 if modTime.After(lastRead) {
196 return fantasy.NewTextErrorResponse(
197 return fantasy.NewTextErrorResponse(
197 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
198 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -292,8 +293,8 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
292 slog.Error("Error creating file history version", "error", err)
293 slog.Error("Error creating file history version", "error", err)
293 }
294 }
294
295
295− recordFileWrite(filePath)
296+ filetracker.RecordWrite(filePath)
296− recordFileRead(filePath)
297+ filetracker.RecordRead(filePath)
297
298
298 return fantasy.WithResponseMetadata(
299 return fantasy.WithResponseMetadata(
299 fantasy.NewTextResponse("Content deleted from file: "+filePath),
300 fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -319,12 +320,12 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
319 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
320 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
320 }
321 }
321
322
322− if getLastReadTime(filePath).IsZero() {
323+ if filetracker.LastReadTime(filePath).IsZero() {
323 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
324 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
324 }
325 }
325
326
326 modTime := fileInfo.ModTime()
327 modTime := fileInfo.ModTime()
327− lastRead := getLastReadTime(filePath)
328+ lastRead := filetracker.LastReadTime(filePath)
328 if modTime.After(lastRead) {
329 if modTime.After(lastRead) {
329 return fantasy.NewTextErrorResponse(
330 return fantasy.NewTextErrorResponse(
330 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
331 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -427,8 +428,8 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
427 slog.Error("Error creating file history version", "error", err)
428 slog.Error("Error creating file history version", "error", err)
428 }
429 }
429
430
430− recordFileWrite(filePath)
431+ filetracker.RecordWrite(filePath)
431− recordFileRead(filePath)
432+ filetracker.RecordRead(filePath)
432
433
433 return fantasy.WithResponseMetadata(
434 return fantasy.WithResponseMetadata(
434 fantasy.NewTextResponse("Content replaced in file: "+filePath),
435 fantasy.NewTextResponse("Content replaced in file: "+filePath),
internal/agent/tools/file.godeleted−53
@@ -1,53 +0,0 @@
1−package tools
2−
3−import (
4− "sync"
5− "time"
6−)
7−
8−// File record to track when files were read/written
9−type fileRecord struct {
10− path string
11− readTime time.Time
12− writeTime time.Time
13−}
14−
15−var (
16− fileRecords = make(map[string]fileRecord)
17− fileRecordMutex sync.RWMutex
18−)
19−
20−func recordFileRead(path string) {
21− fileRecordMutex.Lock()
22− defer fileRecordMutex.Unlock()
23−
24− record, exists := fileRecords[path]
25− if !exists {
26− record = fileRecord{path: path}
27− }
28− record.readTime = time.Now()
29− fileRecords[path] = record
30−}
31−
32−func getLastReadTime(path string) time.Time {
33− fileRecordMutex.RLock()
34− defer fileRecordMutex.RUnlock()
35−
36− record, exists := fileRecords[path]
37− if !exists {
38− return time.Time{}
39− }
40− return record.readTime
41−}
42−
43−func recordFileWrite(path string) {
44− fileRecordMutex.Lock()
45− defer fileRecordMutex.Unlock()
46−
47− record, exists := fileRecords[path]
48− if !exists {
49− record = fileRecord{path: path}
50− }
51− record.writeTime = time.Now()
52− fileRecords[path] = record
53−}
internal/agent/tools/mcp/init.go+2−2
@@ -188,12 +188,12 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
188 return
188 return
189 }
189 }
190
190
191− updateTools(name, tools)
191+ toolCount := updateTools(name, tools)
192 updatePrompts(name, prompts)
192 updatePrompts(name, prompts)
193 sessions.Set(name, session)
193 sessions.Set(name, session)
194
194
195 updateState(name, StateConnected, nil, session, Counts{
195 updateState(name, StateConnected, nil, session, Counts{
196− Tools: len(tools),
196+ Tools: toolCount,
197 Prompts: len(prompts),
197 Prompts: len(prompts),
198 })
198 })
199 }(name, m)
199 }(name, m)
internal/agent/tools/mcp/tools.go+25−4
@@ -6,8 +6,10 @@ import (
6 "fmt"
6 "fmt"
7 "iter"
7 "iter"
8 "log/slog"
8 "log/slog"
9+ "slices"
9 "strings"
10 "strings"
10
11
12+ "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/modelcontextprotocol/go-sdk/mcp"
14 "github.com/modelcontextprotocol/go-sdk/mcp"
13)
15)
@@ -119,10 +121,10 @@ func RefreshTools(ctx context.Context, name string) {
119 return
121 return
120 }
122 }
121
123
122− updateTools(name, tools)
124+ toolCount := updateTools(name, tools)
123
125
124 prev, _ := states.Get(name)
126 prev, _ := states.Get(name)
125− prev.Counts.Tools = len(tools)
127+ prev.Counts.Tools = toolCount
126 updateState(name, StateConnected, nil, session, prev.Counts)
128 updateState(name, StateConnected, nil, session, prev.Counts)
127}
129}
128
130
@@ -137,10 +139,29 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
137 return result.Tools, nil
139 return result.Tools, nil
138}
140}
139
141
140−func updateTools(name string, tools []*Tool) {
142+func updateTools(name string, tools []*Tool) int {
143+ tools = filterDisabledTools(name, tools)
141 if len(tools) == 0 {
144 if len(tools) == 0 {
142 allTools.Del(name)
145 allTools.Del(name)
143− return
146+ return 0
144 }
147 }
145 allTools.Set(name, tools)
148 allTools.Set(name, tools)
149+ return len(tools)
150+}
151+
152+// filterDisabledTools removes tools that are disabled via config.
153+func filterDisabledTools(mcpName string, tools []*Tool) []*Tool {
154+ cfg := config.Get()
155+ mcpCfg, ok := cfg.MCP[mcpName]
156+ if !ok || len(mcpCfg.DisabledTools) == 0 {
157+ return tools
158+ }
159+
160+ filtered := make([]*Tool, 0, len(tools))
161+ for _, tool := range tools {
162+ if !slices.Contains(mcpCfg.DisabledTools, tool.Name) {
163+ filtered = append(filtered, tool)
164+ }
165+ }
166+ return filtered
146}
167}
internal/agent/tools/multiedit.go+7−6
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
16 "github.com/charmbracelet/crush/internal/filepathext"
17+ "github.com/charmbracelet/crush/internal/filetracker"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/lsp"
@@ -206,8 +207,8 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
206 slog.Error("Error creating file history version", "error", err)
207 slog.Error("Error creating file history version", "error", err)
207 }
208 }
208
209
209− recordFileWrite(params.FilePath)
210+ filetracker.RecordWrite(params.FilePath)
210− recordFileRead(params.FilePath)
211+ filetracker.RecordRead(params.FilePath)
211
212
212 var message string
213 var message string
213 if len(failedEdits) > 0 {
214 if len(failedEdits) > 0 {
@@ -244,13 +245,13 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
244 }
245 }
245
246
246 // Check if file was read before editing
247 // Check if file was read before editing
247− if getLastReadTime(params.FilePath).IsZero() {
248+ if filetracker.LastReadTime(params.FilePath).IsZero() {
248 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
249 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
249 }
250 }
250
251
251 // Check if file was modified since last read
252 // Check if file was modified since last read
252 modTime := fileInfo.ModTime()
253 modTime := fileInfo.ModTime()
253− lastRead := getLastReadTime(params.FilePath)
254+ lastRead := filetracker.LastReadTime(params.FilePath)
254 if modTime.After(lastRead) {
255 if modTime.After(lastRead) {
255 return fantasy.NewTextErrorResponse(
256 return fantasy.NewTextErrorResponse(
256 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
257 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -362,8 +363,8 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
362 slog.Error("Error creating file history version", "error", err)
363 slog.Error("Error creating file history version", "error", err)
363 }
364 }
364
365
365− recordFileWrite(params.FilePath)
366+ filetracker.RecordWrite(params.FilePath)
366− recordFileRead(params.FilePath)
367+ filetracker.RecordRead(params.FilePath)
367
368
368 var message string
369 var message string
369 if len(failedEdits) > 0 {
370 if len(failedEdits) > 0 {
internal/agent/tools/multiedit_test.go+2−1
@@ -7,6 +7,7 @@ import (
7 "testing"
7 "testing"
8
8
9 "github.com/charmbracelet/crush/internal/csync"
9 "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/filetracker"
10 "github.com/charmbracelet/crush/internal/history"
11 "github.com/charmbracelet/crush/internal/history"
11 "github.com/charmbracelet/crush/internal/lsp"
12 "github.com/charmbracelet/crush/internal/lsp"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/permission"
@@ -119,7 +120,7 @@ func TestMultiEditSequentialApplication(t *testing.T) {
119 _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
120 _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
120
121
121 // Simulate reading the file first.
122 // Simulate reading the file first.
122− recordFileRead(testFile)
123+ filetracker.RecordRead(testFile)
123
124
124 // Manually test the sequential application logic.
125 // Manually test the sequential application logic.
125 currentContent := content
126 currentContent := content
internal/agent/tools/view.go+2−1
@@ -15,6 +15,7 @@ import (
15 "charm.land/fantasy"
15 "charm.land/fantasy"
16 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filepathext"
18+ "github.com/charmbracelet/crush/internal/filetracker"
18 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/permission"
20)
21)
@@ -194,7 +195,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
194 }
195 }
195 output += "\n</file>\n"
196 output += "\n</file>\n"
196 output += getDiagnostics(filePath, lspClients)
197 output += getDiagnostics(filePath, lspClients)
197− recordFileRead(filePath)
198+ filetracker.RecordRead(filePath)
198 return fantasy.WithResponseMetadata(
199 return fantasy.WithResponseMetadata(
199 fantasy.NewTextResponse(output),
200 fantasy.NewTextResponse(output),
200 ViewResponseMetadata{
201 ViewResponseMetadata{
internal/agent/tools/write.go+4−3
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
16 "github.com/charmbracelet/crush/internal/filepathext"
17+ "github.com/charmbracelet/crush/internal/filetracker"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/history"
19
20
@@ -72,7 +73,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
72 }
73 }
73
74
74 modTime := fileInfo.ModTime()
75 modTime := fileInfo.ModTime()
75− lastRead := getLastReadTime(filePath)
76+ lastRead := filetracker.LastReadTime(filePath)
76 if modTime.After(lastRead) {
77 if modTime.After(lastRead) {
77 return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
78 return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
78 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
79 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -156,8 +157,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
156 slog.Error("Error creating file history version", "error", err)
157 slog.Error("Error creating file history version", "error", err)
157 }
158 }
158
159
159− recordFileWrite(filePath)
160+ filetracker.RecordWrite(filePath)
160− recordFileRead(filePath)
161+ filetracker.RecordRead(filePath)
161
162
162 notifyLSPs(ctx, lspClients, params.FilePath)
163 notifyLSPs(ctx, lspClients, params.FilePath)
163
164
internal/cmd/login.go+1−64
@@ -6,14 +6,12 @@ import (
6 "fmt"
6 "fmt"
7 "os"
7 "os"
8 "os/signal"
8 "os/signal"
9− "strings"
10
9
11 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
12 "github.com/atotto/clipboard"
11 "github.com/atotto/clipboard"
13 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
12 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/oauth"
14 "github.com/charmbracelet/crush/internal/oauth"
16− "github.com/charmbracelet/crush/internal/oauth/claude"
17 "github.com/charmbracelet/crush/internal/oauth/copilot"
15 "github.com/charmbracelet/crush/internal/oauth/copilot"
18 "github.com/charmbracelet/crush/internal/oauth/hyper"
16 "github.com/charmbracelet/crush/internal/oauth/hyper"
19 "github.com/pkg/browser"
17 "github.com/pkg/browser"
@@ -26,21 +24,16 @@ var loginCmd = &cobra.Command{
26 Short: "Login Crush to a platform",
24 Short: "Login Crush to a platform",
27 Long: `Login Crush to a specified platform.
25 Long: `Login Crush to a specified platform.
28The platform should be provided as an argument.
26The platform should be provided as an argument.
29−Available platforms are: hyper, claude, copilot.`,
27+Available platforms are: hyper, copilot.`,
30 Example: `
28 Example: `
31# Authenticate with Charm Hyper
29# Authenticate with Charm Hyper
32crush login
30crush login
33
31
34−# Authenticate with Claude Code Max
35−crush login claude
36−
37# Authenticate with GitHub Copilot
32# Authenticate with GitHub Copilot
38crush login copilot
33crush login copilot
39 `,
34 `,
40 ValidArgs: []cobra.Completion{
35 ValidArgs: []cobra.Completion{
41 "hyper",
36 "hyper",
42− "claude",
43− "anthropic",
44 "copilot",
37 "copilot",
45 "github",
38 "github",
46 "github-copilot",
39 "github-copilot",
@@ -60,8 +53,6 @@ crush login copilot
60 switch provider {
53 switch provider {
61 case "hyper":
54 case "hyper":
62 return loginHyper()
55 return loginHyper()
63− case "anthropic", "claude":
64− return loginClaude()
65 case "copilot", "github", "github-copilot":
56 case "copilot", "github", "github-copilot":
66 return loginCopilot()
57 return loginCopilot()
67 default:
58 default:
@@ -133,60 +124,6 @@ func loginHyper() error {
133 return nil
124 return nil
134}
125}
135
126
136−func loginClaude() error {
137− ctx := getLoginContext()
138−
139− cfg := config.Get()
140− if cfg.HasConfigField("providers.anthropic.oauth") {
141− fmt.Println("You are already logged in to Claude.")
142− return nil
143− }
144−
145− verifier, challenge, err := claude.GetChallenge()
146− if err != nil {
147− return err
148− }
149− url, err := claude.AuthorizeURL(verifier, challenge)
150− if err != nil {
151− return err
152− }
153− fmt.Println("Open the following URL and follow the instructions to authenticate with Claude Code Max:")
154− fmt.Println()
155− fmt.Println(lipgloss.NewStyle().Hyperlink(url, "id=claude").Render(url))
156− fmt.Println()
157− fmt.Println("Press enter to continue...")
158− if _, err := fmt.Scanln(); err != nil {
159− return err
160− }
161−
162− fmt.Println("Now paste and code from Anthropic and press enter...")
163− fmt.Println()
164− fmt.Print("> ")
165− var code string
166− for code == "" {
167− _, _ = fmt.Scanln(&code)
168− code = strings.TrimSpace(code)
169− }
170−
171− fmt.Println()
172− fmt.Println("Exchanging authorization code...")
173− token, err := claude.ExchangeToken(ctx, code, verifier)
174− if err != nil {
175− return err
176− }
177−
178− if err := cmp.Or(
179− cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
180− cfg.SetConfigField("providers.anthropic.oauth", token),
181− ); err != nil {
182− return err
183− }
184−
185− fmt.Println()
186− fmt.Println("You're now authenticated with Claude Code Max!")
187− return nil
188−}
189−
190func loginCopilot() error {
127func loginCopilot() error {
191 ctx := getLoginContext()
128 ctx := getLoginContext()
192
129
internal/cmd/run.go+1−1
@@ -59,7 +59,7 @@ crush run --quiet "Generate a README for this project"
59 return fmt.Errorf("no prompt provided")
59 return fmt.Errorf("no prompt provided")
60 }
60 }
61
61
62− event.SetInteractive(true)
62+ event.SetNonInteractive(true)
63 event.AppInitialized()
63 event.AppInitialized()
64
64
65 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
65 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
internal/config/config.go+19−22
@@ -19,7 +19,6 @@ import (
19 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/env"
20 "github.com/charmbracelet/crush/internal/env"
21 "github.com/charmbracelet/crush/internal/oauth"
21 "github.com/charmbracelet/crush/internal/oauth"
22− "github.com/charmbracelet/crush/internal/oauth/claude"
23 "github.com/charmbracelet/crush/internal/oauth/copilot"
22 "github.com/charmbracelet/crush/internal/oauth/copilot"
24 "github.com/charmbracelet/crush/internal/oauth/hyper"
23 "github.com/charmbracelet/crush/internal/oauth/hyper"
25 "github.com/invopop/jsonschema"
24 "github.com/invopop/jsonschema"
@@ -160,21 +159,6 @@ func (pc *ProviderConfig) ToProvider() catwalk.Provider {
160 return provider
159 return provider
161}
160}
162
161
163−func (pc *ProviderConfig) SetupClaudeCode() {
164− pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
165− pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
166−
167− value := pc.ExtraHeaders["anthropic-beta"]
168− const want = "oauth-2025-04-20"
169− if !strings.Contains(value, want) {
170− if value != "" {
171− value += ","
172− }
173− value += want
174− }
175− pc.ExtraHeaders["anthropic-beta"] = value
176−}
177−
178func (pc *ProviderConfig) SetupGitHubCopilot() {
162func (pc *ProviderConfig) SetupGitHubCopilot() {
179 maps.Copy(pc.ExtraHeaders, copilot.Headers())
163 maps.Copy(pc.ExtraHeaders, copilot.Headers())
180}
164}
@@ -527,6 +511,25 @@ func (c *Config) SetConfigField(key string, value any) error {
527 return nil
511 return nil
528}
512}
529
513
514+func (c *Config) RemoveConfigField(key string) error {
515+ data, err := os.ReadFile(c.dataConfigDir)
516+ if err != nil {
517+ return fmt.Errorf("failed to read config file: %w", err)
518+ }
519+
520+ newValue, err := sjson.Delete(string(data), key)
521+ if err != nil {
522+ return fmt.Errorf("failed to delete config field %s: %w", key, err)
523+ }
524+ if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
525+ return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
526+ }
527+ if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
528+ return fmt.Errorf("failed to write config file: %w", err)
529+ }
530+ return nil
531+}
532+
530// RefreshOAuthToken refreshes the OAuth token for the given provider.
533// RefreshOAuthToken refreshes the OAuth token for the given provider.
531func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
534func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
532 providerConfig, exists := c.Providers.Get(providerID)
535 providerConfig, exists := c.Providers.Get(providerID)
@@ -541,8 +544,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
541 var newToken *oauth.Token
544 var newToken *oauth.Token
542 var refreshErr error
545 var refreshErr error
543 switch providerID {
546 switch providerID {
544− case string(catwalk.InferenceProviderAnthropic):
545− newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
546 case string(catwalk.InferenceProviderCopilot):
547 case string(catwalk.InferenceProviderCopilot):
547 newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
548 newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
548 case hyperp.Name:
549 case hyperp.Name:
@@ -559,8 +560,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
559 providerConfig.APIKey = newToken.AccessToken
560 providerConfig.APIKey = newToken.AccessToken
560
561
561 switch providerID {
562 switch providerID {
562− case string(catwalk.InferenceProviderAnthropic):
563− providerConfig.SetupClaudeCode()
564 case string(catwalk.InferenceProviderCopilot):
563 case string(catwalk.InferenceProviderCopilot):
565 providerConfig.SetupGitHubCopilot()
564 providerConfig.SetupGitHubCopilot()
566 }
565 }
@@ -599,8 +598,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
599 providerConfig.APIKey = v.AccessToken
598 providerConfig.APIKey = v.AccessToken
600 providerConfig.OAuthToken = v
599 providerConfig.OAuthToken = v
601 switch providerID {
600 switch providerID {
602− case string(catwalk.InferenceProviderAnthropic):
603− providerConfig.SetupClaudeCode()
604 case string(catwalk.InferenceProviderCopilot):
601 case string(catwalk.InferenceProviderCopilot):
605 providerConfig.SetupGitHubCopilot()
602 providerConfig.SetupGitHubCopilot()
606 }
603 }
internal/config/load.go+27−20
@@ -202,11 +202,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
202
202
203 switch {
203 switch {
204 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
204 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
205− prepared.SetupClaudeCode()
205+ // Claude Code subscription is not supported anymore. Remove to show onboarding.
206+ c.RemoveConfigField("providers.anthropic")
207+ c.Providers.Del(string(p.ID))
208+ continue
206− case p.ID == catwalk.InferenceProviderCopilot:
209+ case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
207− if config.OAuthToken != nil {
208− prepared.SetupGitHubCopilot()
210+ prepared.SetupGitHubCopilot()
209− }
210 }
211 }
211
212
212 switch p.ID {
213 switch p.ID {
@@ -365,10 +366,11 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
365 slices.Sort(c.Options.ContextPaths)
366 slices.Sort(c.Options.ContextPaths)
366 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
367 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
367
368
368− // Add the default skills directory if not already present.
369+ // Add the default skills directories if not already present.
369− defaultSkillsDir := GlobalSkillsDir()
370+ for _, dir := range GlobalSkillsDirs() {
370− if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
371+ if !slices.Contains(c.Options.SkillsPaths, dir) {
371− c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
372+ c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
373+ }
372 }
374 }
373
375
374 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
376 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
@@ -746,24 +748,29 @@ func isInsideWorktree() bool {
746 return err == nil && strings.TrimSpace(string(bts)) == "true"
748 return err == nil && strings.TrimSpace(string(bts)) == "true"
747}
749}
748
750
749−// GlobalSkillsDir returns the default directory for Agent Skills.
751+// GlobalSkillsDirs returns the default directories for Agent Skills.
750−// Skills in this directory are auto-discovered and their files can be read
752+// Skills in these directories are auto-discovered and their files can be read
751// without permission prompts.
753// without permission prompts.
752−func GlobalSkillsDir() string {
754+func GlobalSkillsDirs() []string {
753 if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
755 if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
754− return crushSkills
756+ return []string{crushSkills}
755− }
756− if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
757− return filepath.Join(xdgConfigHome, appName, "skills")
758 }
757 }
759
758
759+ // Determine the base config directory.
760+ var configBase string
761+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
762+ configBase = xdgConfigHome
760− if runtime.GOOS == "windows" {
763+ } else if runtime.GOOS == "windows" {
761− localAppData := cmp.Or(
764+ configBase = cmp.Or(
762 os.Getenv("LOCALAPPDATA"),
765 os.Getenv("LOCALAPPDATA"),
763 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
766 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
764 )
767 )
765− return filepath.Join(localAppData, appName, "skills")
768+ } else {
769+ configBase = filepath.Join(home.Dir(), ".config")
766 }
770 }
767
771
772+ return []string{
768− return filepath.Join(home.Dir(), ".config", appName, "skills")
773+ filepath.Join(configBase, appName, "skills"),
774+ filepath.Join(configBase, "agents", "skills"),
775+ }
769}
776}
internal/event/event.go+5−3
@@ -15,6 +15,8 @@ import (
15const (
15const (
16 endpoint = "https://data.charm.land"
16 endpoint = "https://data.charm.land"
17 key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
17 key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
18+
19+ nonInteractiveEventName = "NonInteractive"
18)
20)
19
21
20var (
22var (
@@ -27,11 +29,11 @@ var (
27 Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
29 Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
28 Set("Version", version.Version).
30 Set("Version", version.Version).
29 Set("GoVersion", runtime.Version()).
31 Set("GoVersion", runtime.Version()).
30− Set("Interactive", false)
32+ Set(nonInteractiveEventName, false)
31)
33)
32
34
33−func SetInteractive(interactive bool) {
35+func SetNonInteractive(nonInteractive bool) {
34− baseProps = baseProps.Set("Interactive", interactive)
36+ baseProps = baseProps.Set(nonInteractiveEventName, nonInteractive)
35}
37}
36
38
37func Init() {
39func Init() {
internal/filetracker/filetracker.gonew file+70
@@ -0,0 +1,70 @@
1+// Package filetracker tracks file read/write times to prevent editing files
2+// that haven't been read, and to detect external modifications.
3+//
4+// TODO: Consider moving this to persistent storage (e.g., the database) to
5+// preserve file access history across sessions.
6+// We would need to make sure to handle the case where we reload a session and the underlying files did change.
7+package filetracker
8+
9+import (
10+ "sync"
11+ "time"
12+)
13+
14+// record tracks when a file was read/written.
15+type record struct {
16+ path string
17+ readTime time.Time
18+ writeTime time.Time
19+}
20+
21+var (
22+ records = make(map[string]record)
23+ recordMutex sync.RWMutex
24+)
25+
26+// RecordRead records when a file was read.
27+func RecordRead(path string) {
28+ recordMutex.Lock()
29+ defer recordMutex.Unlock()
30+
31+ rec, exists := records[path]
32+ if !exists {
33+ rec = record{path: path}
34+ }
35+ rec.readTime = time.Now()
36+ records[path] = rec
37+}
38+
39+// LastReadTime returns when a file was last read. Returns zero time if never
40+// read.
41+func LastReadTime(path string) time.Time {
42+ recordMutex.RLock()
43+ defer recordMutex.RUnlock()
44+
45+ rec, exists := records[path]
46+ if !exists {
47+ return time.Time{}
48+ }
49+ return rec.readTime
50+}
51+
52+// RecordWrite records when a file was written.
53+func RecordWrite(path string) {
54+ recordMutex.Lock()
55+ defer recordMutex.Unlock()
56+
57+ rec, exists := records[path]
58+ if !exists {
59+ rec = record{path: path}
60+ }
61+ rec.writeTime = time.Now()
62+ records[path] = rec
63+}
64+
65+// Reset clears all file tracking records. Useful for testing.
66+func Reset() {
67+ recordMutex.Lock()
68+ defer recordMutex.Unlock()
69+ records = make(map[string]record)
70+}
internal/oauth/claude/challenge.godeleted−28
@@ -1,28 +0,0 @@
1−package claude
2−
3−import (
4− "crypto/rand"
5− "crypto/sha256"
6− "encoding/base64"
7− "strings"
8−)
9−
10−// GetChallenge generates a PKCE verifier and its corresponding challenge.
11−func GetChallenge() (verifier string, challenge string, err error) {
12− bytes := make([]byte, 32)
13− if _, err := rand.Read(bytes); err != nil {
14− return "", "", err
15− }
16− verifier = encodeBase64(bytes)
17− hash := sha256.Sum256([]byte(verifier))
18− challenge = encodeBase64(hash[:])
19− return verifier, challenge, nil
20−}
21−
22−func encodeBase64(input []byte) (encoded string) {
23− encoded = base64.StdEncoding.EncodeToString(input)
24− encoded = strings.ReplaceAll(encoded, "=", "")
25− encoded = strings.ReplaceAll(encoded, "+", "-")
26− encoded = strings.ReplaceAll(encoded, "/", "_")
27− return encoded
28−}
internal/oauth/claude/oauth.godeleted−126
@@ -1,126 +0,0 @@
1−package claude
2−
3−import (
4− "bytes"
5− "context"
6− "encoding/json"
7− "fmt"
8− "io"
9− "net/http"
10− "net/url"
11− "strings"
12− "time"
13−
14− "github.com/charmbracelet/crush/internal/oauth"
15−)
16−
17−const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18−
19−// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
20−func AuthorizeURL(verifier, challenge string) (string, error) {
21− u, err := url.Parse("https://claude.ai/oauth/authorize")
22− if err != nil {
23− return "", err
24− }
25− q := u.Query()
26− q.Set("response_type", "code")
27− q.Set("client_id", clientId)
28− q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
29− q.Set("scope", "org:create_api_key user:profile user:inference")
30− q.Set("code_challenge", challenge)
31− q.Set("code_challenge_method", "S256")
32− q.Set("state", verifier)
33− u.RawQuery = q.Encode()
34− return u.String(), nil
35−}
36−
37−// ExchangeToken exchanges the authorization code for an OAuth2 token.
38−func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
39− code = strings.TrimSpace(code)
40− parts := strings.SplitN(code, "#", 2)
41− pure := parts[0]
42− state := ""
43− if len(parts) > 1 {
44− state = parts[1]
45− }
46−
47− reqBody := map[string]string{
48− "code": pure,
49− "state": state,
50− "grant_type": "authorization_code",
51− "client_id": clientId,
52− "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
53− "code_verifier": verifier,
54− }
55−
56− resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
57− if err != nil {
58− return nil, err
59− }
60− defer resp.Body.Close()
61−
62− body, err := io.ReadAll(resp.Body)
63− if err != nil {
64− return nil, err
65− }
66−
67− if resp.StatusCode != http.StatusOK {
68− return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
69− }
70−
71− var token oauth.Token
72− if err := json.Unmarshal(body, &token); err != nil {
73− return nil, err
74− }
75− token.SetExpiresAt()
76− return &token, nil
77−}
78−
79−// RefreshToken refreshes the OAuth2 token using the provided refresh token.
80−func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
81− reqBody := map[string]string{
82− "grant_type": "refresh_token",
83− "refresh_token": refreshToken,
84− "client_id": clientId,
85− }
86−
87− resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
88− if err != nil {
89− return nil, err
90− }
91− defer resp.Body.Close()
92−
93− body, err := io.ReadAll(resp.Body)
94− if err != nil {
95− return nil, err
96− }
97−
98− if resp.StatusCode != http.StatusOK {
99− return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
100− }
101−
102− var token oauth.Token
103− if err := json.Unmarshal(body, &token); err != nil {
104− return nil, err
105− }
106− token.SetExpiresAt()
107− return &token, nil
108−}
109−
110−func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
111− date, err := json.Marshal(body)
112− if err != nil {
113− return nil, err
114− }
115−
116− req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
117− if err != nil {
118− return nil, err
119− }
120−
121− req.Header.Set("Content-Type", "application/json")
122− req.Header.Set("User-Agent", "anthropic")
123−
124− client := &http.Client{Timeout: 30 * time.Second}
125− return client.Do(req)
126−}
internal/tui/components/chat/editor/editor.go+11
@@ -18,6 +18,7 @@ import (
18 tea "charm.land/bubbletea/v2"
18 tea "charm.land/bubbletea/v2"
19 "charm.land/lipgloss/v2"
19 "charm.land/lipgloss/v2"
20 "github.com/charmbracelet/crush/internal/app"
20 "github.com/charmbracelet/crush/internal/app"
21+ "github.com/charmbracelet/crush/internal/filetracker"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/session"
24 "github.com/charmbracelet/crush/internal/session"
@@ -202,11 +203,20 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
202 m.currentQuery = ""
203 m.currentQuery = ""
203 m.completionsStartIndex = 0
204 m.completionsStartIndex = 0
204 }
205 }
206+ absPath, _ := filepath.Abs(item.Path)
207+ // Skip attachment if file was already read and hasn't been modified.
208+ lastRead := filetracker.LastReadTime(absPath)
209+ if !lastRead.IsZero() {
210+ if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
211+ return m, nil
212+ }
213+ }
205 content, err := os.ReadFile(item.Path)
214 content, err := os.ReadFile(item.Path)
206 if err != nil {
215 if err != nil {
207 // if it fails, let the LLM handle it later.
216 // if it fails, let the LLM handle it later.
208 return m, nil
217 return m, nil
209 }
218 }
219+ filetracker.RecordRead(absPath)
210 m.attachments = append(m.attachments, message.Attachment{
220 m.attachments = append(m.attachments, message.Attachment{
211 FilePath: item.Path,
221 FilePath: item.Path,
212 FileName: filepath.Base(item.Path),
222 FileName: filepath.Base(item.Path),
@@ -247,6 +257,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
247 if !attachment.IsText() && !attachment.IsImage() {
257 if !attachment.IsText() && !attachment.IsImage() {
248 return m, util.ReportWarn("Invalid file content type: " + mimeType)
258 return m, util.ReportWarn("Invalid file content type: " + mimeType)
249 }
259 }
260+ m.textarea.InsertString(attachment.FileName)
250 return m, util.CmdHandler(filepicker.FilePickedMsg{
261 return m, util.CmdHandler(filepicker.FilePickedMsg{
251 Attachment: attachment,
262 Attachment: attachment,
252 })
263 })
internal/tui/components/chat/splash/splash.go+2−200
@@ -9,7 +9,6 @@ import (
9 "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12− "github.com/atotto/clipboard"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
14 "github.com/charmbracelet/crush/internal/agent"
13 "github.com/charmbracelet/crush/internal/agent"
15 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
@@ -18,7 +17,6 @@ import (
18 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21− "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -47,18 +45,6 @@ type Splash interface {
47 // IsAPIKeyValid returns whether the API key is valid
45 // IsAPIKeyValid returns whether the API key is valid
48 IsAPIKeyValid() bool
46 IsAPIKeyValid() bool
49
47
50− // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
51− IsShowingClaudeAuthMethodChooser() bool
52−
53− // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
54− IsShowingClaudeOAuth2() bool
55−
56− // IsClaudeOAuthURLState returns whether in OAuth URL state
57− IsClaudeOAuthURLState() bool
58−
59− // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
60− IsClaudeOAuthComplete() bool
61−
62 // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
48 // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
63 IsShowingHyperOAuth2() bool
49 IsShowingHyperOAuth2() bool
64
50
@@ -103,12 +89,6 @@ type splashCmp struct {
103 // Copilot device flow state
89 // Copilot device flow state
104 copilotDeviceFlow *copilot.DeviceFlow
90 copilotDeviceFlow *copilot.DeviceFlow
105 showCopilotDeviceFlow bool
91 showCopilotDeviceFlow bool
106−
107− // Claude state
108− claudeAuthMethodChooser *claude.AuthMethodChooser
109− claudeOAuth2 *claude.OAuth2
110− showClaudeAuthMethodChooser bool
111− showClaudeOAuth2 bool
112}
92}
113
93
114func New() Splash {
94func New() Splash {
@@ -134,9 +114,6 @@ func New() Splash {
134 modelList: modelList,
114 modelList: modelList,
135 apiKeyInput: apiKeyInput,
115 apiKeyInput: apiKeyInput,
136 selectedNo: false,
116 selectedNo: false,
137−
138− claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
139− claudeOAuth2: claude.NewOAuth2(),
140 }
117 }
141}
118}
142
119
@@ -158,8 +135,6 @@ func (s *splashCmp) Init() tea.Cmd {
158 return tea.Batch(
135 return tea.Batch(
159 s.modelList.Init(),
136 s.modelList.Init(),
160 s.apiKeyInput.Init(),
137 s.apiKeyInput.Init(),
161− s.claudeAuthMethodChooser.Init(),
162− s.claudeOAuth2.Init(),
163 )
138 )
164}
139}
165
140
@@ -176,7 +151,6 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
176 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
151 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
177 listWidth := min(60, width)
152 listWidth := min(60, width)
178 s.apiKeyInput.SetWidth(width - 2)
153 s.apiKeyInput.SetWidth(width - 2)
179− s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
180 return s.modelList.SetSize(listWidth, s.listHeight)
154 return s.modelList.SetSize(listWidth, s.listHeight)
181}
155}
182
156
@@ -185,24 +159,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
185 switch msg := msg.(type) {
159 switch msg := msg.(type) {
186 case tea.WindowSizeMsg:
160 case tea.WindowSizeMsg:
187 return s, s.SetSize(msg.Width, msg.Height)
161 return s, s.SetSize(msg.Width, msg.Height)
188− case claude.ValidationCompletedMsg:
189− var cmds []tea.Cmd
190− u, cmd := s.claudeOAuth2.Update(msg)
191− s.claudeOAuth2 = u.(*claude.OAuth2)
192− cmds = append(cmds, cmd)
193−
194− if msg.State == claude.OAuthValidationStateValid {
195− cmds = append(
196− cmds,
197− s.saveAPIKeyAndContinue(msg.Token, false),
198− func() tea.Msg {
199− time.Sleep(5 * time.Second)
200− return claude.AuthenticationCompleteMsg{}
201− },
202− )
203− }
204−
205− return s, tea.Batch(cmds...)
206 case hyper.DeviceFlowCompletedMsg:
162 case hyper.DeviceFlowCompletedMsg:
207 s.showHyperDeviceFlow = false
163 s.showHyperDeviceFlow = false
208 return s, s.saveAPIKeyAndContinue(msg.Token, true)
164 return s, s.saveAPIKeyAndContinue(msg.Token, true)
@@ -223,10 +179,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
223 case copilot.DeviceFlowCompletedMsg:
179 case copilot.DeviceFlowCompletedMsg:
224 s.showCopilotDeviceFlow = false
180 s.showCopilotDeviceFlow = false
225 return s, s.saveAPIKeyAndContinue(msg.Token, true)
181 return s, s.saveAPIKeyAndContinue(msg.Token, true)
226− case claude.AuthenticationCompleteMsg:
227− s.showClaudeAuthMethodChooser = false
228− s.showClaudeOAuth2 = false
229− return s, util.CmdHandler(OnboardingCompleteMsg{})
230 case models.APIKeyStateChangeMsg:
182 case models.APIKeyStateChangeMsg:
231 u, cmd := s.apiKeyInput.Update(msg)
183 u, cmd := s.apiKeyInput.Update(msg)
232 s.apiKeyInput = u.(*models.APIKeyInput)
184 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -246,34 +198,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
246 return s, s.hyperDeviceFlow.CopyCode()
198 return s, s.hyperDeviceFlow.CopyCode()
247 case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
199 case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
248 return s, s.copilotDeviceFlow.CopyCode()
200 return s, s.copilotDeviceFlow.CopyCode()
249− case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
250− return s, tea.Sequence(
251− tea.SetClipboard(s.claudeOAuth2.URL),
252− func() tea.Msg {
253− _ = clipboard.WriteAll(s.claudeOAuth2.URL)
254− return nil
255− },
256− util.ReportInfo("URL copied to clipboard"),
257− )
258− case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
259− u, cmd := s.claudeAuthMethodChooser.Update(msg)
260− s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
261− return s, cmd
262− case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
263− u, cmd := s.claudeOAuth2.Update(msg)
264− s.claudeOAuth2 = u.(*claude.OAuth2)
265− return s, cmd
266 case key.Matches(msg, s.keyMap.Back):
201 case key.Matches(msg, s.keyMap.Back):
267 switch {
202 switch {
268− case s.showClaudeAuthMethodChooser:
269− s.claudeAuthMethodChooser.SetDefaults()
270− s.showClaudeAuthMethodChooser = false
271− return s, nil
272− case s.showClaudeOAuth2:
273− s.claudeOAuth2.SetDefaults()
274− s.showClaudeOAuth2 = false
275− s.showClaudeAuthMethodChooser = true
276− return s, nil
277 case s.showHyperDeviceFlow:
203 case s.showHyperDeviceFlow:
278 s.hyperDeviceFlow = nil
204 s.hyperDeviceFlow = nil
279 s.showHyperDeviceFlow = false
205 s.showHyperDeviceFlow = false
@@ -285,9 +211,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
285 case s.isAPIKeyValid:
211 case s.isAPIKeyValid:
286 return s, nil
212 return s, nil
287 case s.needsAPIKey:
213 case s.needsAPIKey:
288− if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
289− s.showClaudeAuthMethodChooser = true
290− }
291 s.needsAPIKey = false
214 s.needsAPIKey = false
292 s.selectedModel = nil
215 s.selectedModel = nil
293 s.isAPIKeyValid = false
216 s.isAPIKeyValid = false
@@ -297,28 +220,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
297 }
220 }
298 case key.Matches(msg, s.keyMap.Select):
221 case key.Matches(msg, s.keyMap.Select):
299 switch {
222 switch {
300− case s.showClaudeAuthMethodChooser:
301− selectedItem := s.modelList.SelectedModel()
302− if selectedItem == nil {
303− return s, nil
304− }
305−
306− switch s.claudeAuthMethodChooser.State {
307− case claude.AuthMethodAPIKey:
308− s.showClaudeAuthMethodChooser = false
309− s.needsAPIKey = true
310− s.selectedModel = selectedItem
311− s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
312− case claude.AuthMethodOAuth2:
313− s.selectedModel = selectedItem
314− s.showClaudeAuthMethodChooser = false
315− s.showClaudeOAuth2 = true
316− }
317− return s, nil
318− case s.showClaudeOAuth2:
319− m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
320− s.claudeOAuth2 = m2.(*claude.OAuth2)
321− return s, cmd2
322 case s.showHyperDeviceFlow:
223 case s.showHyperDeviceFlow:
323 return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
224 return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
324 case s.showCopilotDeviceFlow:
225 case s.showCopilotDeviceFlow:
@@ -336,9 +237,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
336 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
237 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
337 } else {
238 } else {
338 switch selectedItem.Provider.ID {
239 switch selectedItem.Provider.ID {
339− case catwalk.InferenceProviderAnthropic:
340− s.showClaudeAuthMethodChooser = true
341− return s, nil
342 case hyperp.Name:
240 case hyperp.Name:
343 s.selectedModel = selectedItem
241 s.selectedModel = selectedItem
344 s.showHyperDeviceFlow = true
242 s.showHyperDeviceFlow = true
@@ -407,10 +305,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
407 return s, s.initializeProject()
305 return s, s.initializeProject()
408 }
306 }
409 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
307 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
410− if s.showClaudeAuthMethodChooser {
411− s.claudeAuthMethodChooser.ToggleChoice()
412− return s, nil
413− }
414 if s.needsAPIKey {
308 if s.needsAPIKey {
415 u, cmd := s.apiKeyInput.Update(msg)
309 u, cmd := s.apiKeyInput.Update(msg)
416 s.apiKeyInput = u.(*models.APIKeyInput)
310 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -452,14 +346,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
452 }
346 }
453 default:
347 default:
454 switch {
348 switch {
455− case s.showClaudeAuthMethodChooser:
456− u, cmd := s.claudeAuthMethodChooser.Update(msg)
457− s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
458− return s, cmd
459− case s.showClaudeOAuth2:
460− u, cmd := s.claudeOAuth2.Update(msg)
461− s.claudeOAuth2 = u.(*claude.OAuth2)
462− return s, cmd
463 case s.showHyperDeviceFlow:
349 case s.showHyperDeviceFlow:
464 u, cmd := s.hyperDeviceFlow.Update(msg)
350 u, cmd := s.hyperDeviceFlow.Update(msg)
465 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
351 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -480,10 +366,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
480 }
366 }
481 case tea.PasteMsg:
367 case tea.PasteMsg:
482 switch {
368 switch {
483− case s.showClaudeOAuth2:
484− u, cmd := s.claudeOAuth2.Update(msg)
485− s.claudeOAuth2 = u.(*claude.OAuth2)
486− return s, cmd
487 case s.showHyperDeviceFlow:
369 case s.showHyperDeviceFlow:
488 u, cmd := s.hyperDeviceFlow.Update(msg)
370 u, cmd := s.hyperDeviceFlow.Update(msg)
489 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
371 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -503,10 +385,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
503 }
385 }
504 case spinner.TickMsg:
386 case spinner.TickMsg:
505 switch {
387 switch {
506− case s.showClaudeOAuth2:
507− u, cmd := s.claudeOAuth2.Update(msg)
508− s.claudeOAuth2 = u.(*claude.OAuth2)
509− return s, cmd
510 case s.showHyperDeviceFlow:
388 case s.showHyperDeviceFlow:
511 u, cmd := s.hyperDeviceFlow.Update(msg)
389 u, cmd := s.hyperDeviceFlow.Update(msg)
512 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
390 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -655,38 +533,6 @@ func (s *splashCmp) View() string {
655 var content string
533 var content string
656
534
657 switch {
535 switch {
658− case s.showClaudeAuthMethodChooser:
659− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
660− chooserView := s.claudeAuthMethodChooser.View()
661− authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
662− lipgloss.JoinVertical(
663− lipgloss.Left,
664− t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
665− "",
666− chooserView,
667− ),
668− )
669− content = lipgloss.JoinVertical(
670− lipgloss.Left,
671− s.logoRendered,
672− authMethodSelector,
673− )
674− case s.showClaudeOAuth2:
675− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
676− oauth2View := s.claudeOAuth2.View()
677− oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
678− lipgloss.JoinVertical(
679− lipgloss.Left,
680− t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
681− "",
682− oauth2View,
683− ),
684− )
685− content = lipgloss.JoinVertical(
686− lipgloss.Left,
687− s.logoRendered,
688− oauthSelector,
689− )
690 case s.showHyperDeviceFlow:
536 case s.showHyperDeviceFlow:
691 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
537 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
692 hyperView := s.hyperDeviceFlow.View()
538 hyperView := s.hyperDeviceFlow.View()
@@ -816,14 +662,6 @@ func (s *splashCmp) View() string {
816
662
817func (s *splashCmp) Cursor() *tea.Cursor {
663func (s *splashCmp) Cursor() *tea.Cursor {
818 switch {
664 switch {
819− case s.showClaudeAuthMethodChooser:
820− return nil
821− case s.showClaudeOAuth2:
822− if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
823− cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
824− return s.moveCursor(cursor)
825− }
826− return nil
827 case s.needsAPIKey:
665 case s.needsAPIKey:
828 cursor := s.apiKeyInput.Cursor()
666 cursor := s.apiKeyInput.Cursor()
829 if cursor != nil {
667 if cursor != nil {
@@ -894,16 +732,10 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
894 }
732 }
895 // Calculate the correct Y offset based on current state
733 // Calculate the correct Y offset based on current state
896 logoHeight := lipgloss.Height(s.logoRendered)
734 logoHeight := lipgloss.Height(s.logoRendered)
897− if s.needsAPIKey || s.showClaudeOAuth2 {
735+ if s.needsAPIKey {
898− var view string
899− if s.needsAPIKey {
900− view = s.apiKeyInput.View()
901− } else {
902− view = s.claudeOAuth2.View()
903− }
904 infoSectionHeight := lipgloss.Height(s.infoSection())
736 infoSectionHeight := lipgloss.Height(s.infoSection())
905 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
737 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
906− remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
738+ remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
907 offset := baseOffset + remainingHeight
739 offset := baseOffset + remainingHeight
908 cursor.Y += offset
740 cursor.Y += offset
909 cursor.X += 1
741 cursor.X += 1
@@ -926,20 +758,6 @@ func (s *splashCmp) logoGap() int {
926// Bindings implements SplashPage.
758// Bindings implements SplashPage.
927func (s *splashCmp) Bindings() []key.Binding {
759func (s *splashCmp) Bindings() []key.Binding {
928 switch {
760 switch {
929− case s.showClaudeAuthMethodChooser:
930− return []key.Binding{
931− s.keyMap.Select,
932− s.keyMap.Tab,
933− s.keyMap.Back,
934− }
935− case s.showClaudeOAuth2:
936− bindings := []key.Binding{
937− s.keyMap.Select,
938− }
939− if s.claudeOAuth2.State == claude.OAuthStateURL {
940− bindings = append(bindings, s.keyMap.Copy)
941− }
942− return bindings
943 case s.needsAPIKey:
761 case s.needsAPIKey:
944 return []key.Binding{
762 return []key.Binding{
945 s.keyMap.Select,
763 s.keyMap.Select,
@@ -1047,22 +865,6 @@ func (s *splashCmp) IsAPIKeyValid() bool {
1047 return s.isAPIKeyValid
865 return s.isAPIKeyValid
1048}
866}
1049
867
1050−func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
1051− return s.showClaudeAuthMethodChooser
1052−}
1053−
1054−func (s *splashCmp) IsShowingClaudeOAuth2() bool {
1055− return s.showClaudeOAuth2
1056−}
1057−
1058−func (s *splashCmp) IsClaudeOAuthURLState() bool {
1059− return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
1060−}
1061−
1062−func (s *splashCmp) IsClaudeOAuthComplete() bool {
1063− return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
1064−}
1065−
1066func (s *splashCmp) IsShowingHyperOAuth2() bool {
868func (s *splashCmp) IsShowingHyperOAuth2() bool {
1067 return s.showHyperDeviceFlow
869 return s.showHyperDeviceFlow
1068}
870}
internal/tui/components/dialogs/claude/method.godeleted−115
@@ -1,115 +0,0 @@
1−package claude
2−
3−import (
4− tea "charm.land/bubbletea/v2"
5− "charm.land/lipgloss/v2"
6− "github.com/charmbracelet/crush/internal/tui/styles"
7− "github.com/charmbracelet/crush/internal/tui/util"
8−)
9−
10−type AuthMethod int
11−
12−const (
13− AuthMethodAPIKey AuthMethod = iota
14− AuthMethodOAuth2
15−)
16−
17−type AuthMethodChooser struct {
18− State AuthMethod
19− width int
20− isOnboarding bool
21−}
22−
23−func NewAuthMethodChooser() *AuthMethodChooser {
24− return &AuthMethodChooser{
25− State: AuthMethodOAuth2,
26− }
27−}
28−
29−func (a *AuthMethodChooser) Init() tea.Cmd {
30− return nil
31−}
32−
33−func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) {
34− return a, nil
35−}
36−
37−func (a *AuthMethodChooser) View() string {
38− t := styles.CurrentTheme()
39−
40− white := lipgloss.NewStyle().Foreground(t.White)
41− primary := lipgloss.NewStyle().Foreground(t.Primary)
42− success := lipgloss.NewStyle().Foreground(t.Success)
43−
44− titleStyle := white
45− if a.isOnboarding {
46− titleStyle = primary
47− }
48−
49− question := lipgloss.
50− NewStyle().
51− Margin(0, 1).
52− Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?"))
53−
54− squareWidth := (a.width - 2) / 2
55− squareHeight := squareWidth / 3
56− if isOdd(squareHeight) {
57− squareHeight++
58− }
59−
60− square := lipgloss.NewStyle().
61− Width(squareWidth).
62− Height(squareHeight).
63− Margin(0, 0).
64− Border(lipgloss.RoundedBorder())
65−
66− squareText := lipgloss.NewStyle().
67− Width(squareWidth - 2).
68− Height(squareHeight).
69− Align(lipgloss.Center).
70− AlignVertical(lipgloss.Center)
71−
72− oauthBorder := t.AuthBorderSelected
73− oauthText := t.AuthTextSelected
74− apiKeyBorder := t.AuthBorderUnselected
75− apiKeyText := t.AuthTextUnselected
76−
77− if a.State == AuthMethodAPIKey {
78− oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder
79− oauthText, apiKeyText = apiKeyText, oauthText
80− }
81−
82− return lipgloss.JoinVertical(
83− lipgloss.Left,
84− question,
85− "",
86− lipgloss.JoinHorizontal(
87− lipgloss.Center,
88− square.MarginLeft(1).
89− Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")),
90− square.MarginRight(1).
91− Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")),
92− ),
93− )
94−}
95−
96−func (a *AuthMethodChooser) SetDefaults() {
97− a.State = AuthMethodOAuth2
98−}
99−
100−func (a *AuthMethodChooser) SetWidth(w int) {
101− a.width = w
102−}
103−
104−func (a *AuthMethodChooser) ToggleChoice() {
105− switch a.State {
106− case AuthMethodAPIKey:
107− a.State = AuthMethodOAuth2
108− case AuthMethodOAuth2:
109− a.State = AuthMethodAPIKey
110− }
111−}
112−
113−func isOdd(n int) bool {
114− return n%2 != 0
115−}
internal/tui/components/dialogs/claude/oauth.godeleted−267
@@ -1,267 +0,0 @@
1−package claude
2−
3−import (
4− "context"
5− "fmt"
6− "net/url"
7−
8− "charm.land/bubbles/v2/spinner"
9− "charm.land/bubbles/v2/textinput"
10− tea "charm.land/bubbletea/v2"
11− "charm.land/lipgloss/v2"
12− "github.com/charmbracelet/crush/internal/oauth"
13− "github.com/charmbracelet/crush/internal/oauth/claude"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/pkg/browser"
17− "github.com/zeebo/xxh3"
18−)
19−
20−type OAuthState int
21−
22−const (
23− OAuthStateURL OAuthState = iota
24− OAuthStateCode
25−)
26−
27−type OAuthValidationState int
28−
29−const (
30− OAuthValidationStateNone OAuthValidationState = iota
31− OAuthValidationStateVerifying
32− OAuthValidationStateValid
33− OAuthValidationStateError
34−)
35−
36−type ValidationCompletedMsg struct {
37− State OAuthValidationState
38− Token *oauth.Token
39−}
40−
41−type AuthenticationCompleteMsg struct{}
42−
43−type OAuth2 struct {
44− State OAuthState
45− ValidationState OAuthValidationState
46− width int
47− isOnboarding bool
48−
49− // URL page
50− err error
51− verifier string
52− challenge string
53− URL string
54− urlId string
55− token *oauth.Token
56−
57− // Code input page
58− CodeInput textinput.Model
59− spinner spinner.Model
60−}
61−
62−func NewOAuth2() *OAuth2 {
63− return &OAuth2{
64− State: OAuthStateURL,
65− }
66−}
67−
68−func (o *OAuth2) Init() tea.Cmd {
69− t := styles.CurrentTheme()
70−
71− verifier, challenge, err := claude.GetChallenge()
72− if err != nil {
73− o.err = err
74− return nil
75− }
76−
77− url, err := claude.AuthorizeURL(verifier, challenge)
78− if err != nil {
79− o.err = err
80− return nil
81− }
82−
83− o.verifier = verifier
84− o.challenge = challenge
85− o.URL = url
86−
87− h := xxh3.New()
88− _, _ = h.WriteString(o.URL)
89− o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
90−
91− o.CodeInput = textinput.New()
92− o.CodeInput.Placeholder = "Paste or type"
93− o.CodeInput.SetVirtualCursor(false)
94− o.CodeInput.Prompt = "> "
95− o.CodeInput.SetStyles(t.S().TextInput)
96− o.CodeInput.SetWidth(50)
97−
98− o.spinner = spinner.New(
99− spinner.WithSpinner(spinner.Dot),
100− spinner.WithStyle(t.S().Base.Foreground(t.Green)),
101− )
102−
103− return nil
104−}
105−
106−func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
107− var cmds []tea.Cmd
108−
109− switch msg := msg.(type) {
110− case ValidationCompletedMsg:
111− o.ValidationState = msg.State
112− o.token = msg.Token
113− switch o.ValidationState {
114− case OAuthValidationStateError:
115− o.CodeInput.Focus()
116− }
117− o.updatePrompt()
118− }
119−
120− if o.ValidationState == OAuthValidationStateVerifying {
121− var cmd tea.Cmd
122− o.spinner, cmd = o.spinner.Update(msg)
123− cmds = append(cmds, cmd)
124− o.updatePrompt()
125− }
126− {
127− var cmd tea.Cmd
128− o.CodeInput, cmd = o.CodeInput.Update(msg)
129− cmds = append(cmds, cmd)
130− }
131−
132− return o, tea.Batch(cmds...)
133−}
134−
135−func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
136− var cmds []tea.Cmd
137−
138− switch {
139− case o.State == OAuthStateURL:
140− _ = browser.OpenURL(o.URL)
141− o.State = OAuthStateCode
142− cmds = append(cmds, o.CodeInput.Focus())
143− case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
144− o.CodeInput.Blur()
145− o.ValidationState = OAuthValidationStateVerifying
146− cmds = append(cmds, o.spinner.Tick, o.validateCode)
147− case o.ValidationState == OAuthValidationStateValid:
148− cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
149− }
150−
151− o.updatePrompt()
152− return o, tea.Batch(cmds...)
153−}
154−
155−func (o *OAuth2) View() string {
156− t := styles.CurrentTheme()
157−
158− whiteStyle := lipgloss.NewStyle().Foreground(t.White)
159− primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
160− successStyle := lipgloss.NewStyle().Foreground(t.Success)
161− errorStyle := lipgloss.NewStyle().Foreground(t.Error)
162−
163− titleStyle := whiteStyle
164− if o.isOnboarding {
165− titleStyle = primaryStyle
166− }
167−
168− switch {
169− case o.err != nil:
170− return lipgloss.NewStyle().
171− Margin(0, 1).
172− Foreground(t.Error).
173− Render(o.err.Error())
174− case o.State == OAuthStateURL:
175− heading := lipgloss.
176− NewStyle().
177− Margin(0, 1).
178− Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
179−
180− return lipgloss.JoinVertical(
181− lipgloss.Left,
182− heading,
183− "",
184− lipgloss.NewStyle().
185− Margin(0, 1).
186− Foreground(t.FgMuted).
187− Hyperlink(o.URL, o.urlId).
188− Render(o.displayUrl()),
189− )
190− case o.State == OAuthStateCode:
191− var heading string
192−
193− switch o.ValidationState {
194− case OAuthValidationStateNone:
195− st := lipgloss.NewStyle().Margin(0, 1)
196− heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
197− case OAuthValidationStateVerifying:
198− heading = titleStyle.Margin(0, 1).Render("Verifying...")
199− case OAuthValidationStateValid:
200− heading = successStyle.Margin(0, 1).Render("Validated.")
201− case OAuthValidationStateError:
202− heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
203− }
204−
205− return lipgloss.JoinVertical(
206− lipgloss.Left,
207− heading,
208− "",
209− " "+o.CodeInput.View(),
210− )
211− default:
212− panic("claude oauth2: invalid state")
213− }
214−}
215−
216−func (o *OAuth2) SetDefaults() {
217− o.State = OAuthStateURL
218− o.ValidationState = OAuthValidationStateNone
219− o.CodeInput.SetValue("")
220− o.err = nil
221−}
222−
223−func (o *OAuth2) SetWidth(w int) {
224− o.width = w
225− o.CodeInput.SetWidth(w - 4)
226−}
227−
228−func (o *OAuth2) SetError(err error) {
229− o.err = err
230−}
231−
232−func (o *OAuth2) validateCode() tea.Msg {
233− token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
234− if err != nil || token == nil {
235− return ValidationCompletedMsg{State: OAuthValidationStateError}
236− }
237− return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
238−}
239−
240−func (o *OAuth2) updatePrompt() {
241− switch o.ValidationState {
242− case OAuthValidationStateNone:
243− o.CodeInput.Prompt = "> "
244− case OAuthValidationStateVerifying:
245− o.CodeInput.Prompt = o.spinner.View() + " "
246− case OAuthValidationStateValid:
247− o.CodeInput.Prompt = styles.CheckIcon + " "
248− case OAuthValidationStateError:
249− o.CodeInput.Prompt = styles.ErrorIcon + " "
250− }
251−}
252−
253−// Remove query params for display
254−// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
255−func (o *OAuth2) displayUrl() string {
256− parsed, err := url.Parse(o.URL)
257− if err != nil {
258− return o.URL
259− }
260−
261− if parsed.RawQuery != "" {
262− parsed.RawQuery = ""
263− return parsed.String() + "..."
264− }
265−
266− return o.URL
267−}
internal/tui/components/dialogs/models/keys.go−57
@@ -18,11 +18,6 @@ type KeyMap struct {
18 isHyperDeviceFlow bool
18 isHyperDeviceFlow bool
19 isCopilotDeviceFlow bool
19 isCopilotDeviceFlow bool
20 isCopilotUnavailable bool
20 isCopilotUnavailable bool
21−
22− isClaudeAuthChoiceHelp bool
23− isClaudeOAuthHelp bool
24− isClaudeOAuthURLState bool
25− isClaudeOAuthHelpComplete bool
26}
21}
27
22
28func DefaultKeyMap() KeyMap {
23func DefaultKeyMap() KeyMap {
@@ -100,58 +95,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
100 k.Close,
95 k.Close,
101 }
96 }
102 }
97 }
103− if k.isClaudeAuthChoiceHelp {
104− return []key.Binding{
105− key.NewBinding(
106− key.WithKeys("left", "right", "h", "l"),
107− key.WithHelp("←→", "choose"),
108− ),
109− key.NewBinding(
110− key.WithKeys("enter"),
111− key.WithHelp("enter", "accept"),
112− ),
113− key.NewBinding(
114− key.WithKeys("esc"),
115− key.WithHelp("esc", "back"),
116− ),
117− }
118− }
119− if k.isClaudeOAuthHelp {
120− if k.isClaudeOAuthHelpComplete {
121− return []key.Binding{
122− key.NewBinding(
123− key.WithKeys("enter"),
124− key.WithHelp("enter", "close"),
125− ),
126− }
127− }
128−
129− enterHelp := "submit"
130− if k.isClaudeOAuthURLState {
131− enterHelp = "open"
132− }
133−
134− bindings := []key.Binding{
135− key.NewBinding(
136− key.WithKeys("enter"),
137− key.WithHelp("enter", enterHelp),
138− ),
139− }
140−
141− if k.isClaudeOAuthURLState {
142− bindings = append(bindings, key.NewBinding(
143− key.WithKeys("c"),
144− key.WithHelp("c", "copy url"),
145− ))
146− }
147−
148− bindings = append(bindings, key.NewBinding(
149− key.WithKeys("esc"),
150− key.WithHelp("esc", "back"),
151− ))
152−
153− return bindings
154− }
155 if k.isAPIKeyHelp && !k.isAPIKeyValid {
98 if k.isAPIKeyHelp && !k.isAPIKeyValid {
156 return []key.Binding{
99 return []key.Binding{
157 key.NewBinding(
100 key.NewBinding(
internal/tui/components/dialogs/models/models.go−122
@@ -10,13 +10,11 @@ import (
10 "charm.land/bubbles/v2/spinner"
10 "charm.land/bubbles/v2/spinner"
11 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2"
13− "github.com/atotto/clipboard"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19− "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
22 "github.com/charmbracelet/crush/internal/tui/exp/list"
20 "github.com/charmbracelet/crush/internal/tui/exp/list"
@@ -81,12 +79,6 @@ type modelDialogCmp struct {
81 // Copilot device flow state
79 // Copilot device flow state
82 copilotDeviceFlow *copilot.DeviceFlow
80 copilotDeviceFlow *copilot.DeviceFlow
83 showCopilotDeviceFlow bool
81 showCopilotDeviceFlow bool
84−
85− // Claude state
86− claudeAuthMethodChooser *claude.AuthMethodChooser
87− claudeOAuth2 *claude.OAuth2
88− showClaudeAuthMethodChooser bool
89− showClaudeOAuth2 bool
90}
82}
91
83
92func NewModelDialogCmp() ModelDialog {
84func NewModelDialogCmp() ModelDialog {
@@ -111,9 +103,6 @@ func NewModelDialogCmp() ModelDialog {
111 width: defaultWidth,
103 width: defaultWidth,
112 keyMap: DefaultKeyMap(),
104 keyMap: DefaultKeyMap(),
113 help: help,
105 help: help,
114−
115− claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
116− claudeOAuth2: claude.NewOAuth2(),
117 }
106 }
118}
107}
119
108
@@ -121,8 +110,6 @@ func (m *modelDialogCmp) Init() tea.Cmd {
121 return tea.Batch(
110 return tea.Batch(
122 m.modelList.Init(),
111 m.modelList.Init(),
123 m.apiKeyInput.Init(),
112 m.apiKeyInput.Init(),
124− m.claudeAuthMethodChooser.Init(),
125− m.claudeOAuth2.Init(),
126 )
113 )
127}
114}
128
115
@@ -133,7 +120,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
133 m.wHeight = msg.Height
120 m.wHeight = msg.Height
134 m.apiKeyInput.SetWidth(m.width - 2)
121 m.apiKeyInput.SetWidth(m.width - 2)
135 m.help.SetWidth(m.width - 2)
122 m.help.SetWidth(m.width - 2)
136− m.claudeAuthMethodChooser.SetWidth(m.width - 2)
137 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
123 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
138 case APIKeyStateChangeMsg:
124 case APIKeyStateChangeMsg:
139 u, cmd := m.apiKeyInput.Update(msg)
125 u, cmd := m.apiKeyInput.Update(msg)
@@ -157,20 +143,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
157 return m, nil
143 return m, nil
158 case copilot.DeviceFlowCompletedMsg:
144 case copilot.DeviceFlowCompletedMsg:
159 return m, m.saveOauthTokenAndContinue(msg.Token, true)
145 return m, m.saveOauthTokenAndContinue(msg.Token, true)
160− case claude.ValidationCompletedMsg:
161− var cmds []tea.Cmd
162− u, cmd := m.claudeOAuth2.Update(msg)
163− m.claudeOAuth2 = u.(*claude.OAuth2)
164− cmds = append(cmds, cmd)
165−
166− if msg.State == claude.OAuthValidationStateValid {
167− cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
168− m.keyMap.isClaudeOAuthHelpComplete = true
169− }
170−
171− return m, tea.Batch(cmds...)
172− case claude.AuthenticationCompleteMsg:
173− return m, util.CmdHandler(dialogs.CloseDialogMsg{})
174 case tea.KeyPressMsg:
146 case tea.KeyPressMsg:
175 switch {
147 switch {
176 // Handle Hyper device flow keys
148 // Handle Hyper device flow keys
@@ -178,18 +150,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
178 return m, m.hyperDeviceFlow.CopyCode()
150 return m, m.hyperDeviceFlow.CopyCode()
179 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
151 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
180 return m, m.copilotDeviceFlow.CopyCode()
152 return m, m.copilotDeviceFlow.CopyCode()
181− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
182− return m, tea.Sequence(
183− tea.SetClipboard(m.claudeOAuth2.URL),
184− func() tea.Msg {
185− _ = clipboard.WriteAll(m.claudeOAuth2.URL)
186− return nil
187− },
188− util.ReportInfo("URL copied to clipboard"),
189− )
190− case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
191− m.claudeAuthMethodChooser.ToggleChoice()
192− return m, nil
193 case key.Matches(msg, m.keyMap.Select):
153 case key.Matches(msg, m.keyMap.Select):
194 // If showing device flow, enter copies code and opens URL
154 // If showing device flow, enter copies code and opens URL
195 if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
155 if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
@@ -209,37 +169,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
209 }
169 }
210
170
211 askForApiKey := func() {
171 askForApiKey := func() {
212− m.keyMap.isClaudeAuthChoiceHelp = false
213− m.keyMap.isClaudeOAuthHelp = false
214 m.keyMap.isAPIKeyHelp = true
172 m.keyMap.isAPIKeyHelp = true
215 m.showHyperDeviceFlow = false
173 m.showHyperDeviceFlow = false
216 m.showCopilotDeviceFlow = false
174 m.showCopilotDeviceFlow = false
217− m.showClaudeAuthMethodChooser = false
218 m.needsAPIKey = true
175 m.needsAPIKey = true
219 m.selectedModel = selectedItem
176 m.selectedModel = selectedItem
220 m.selectedModelType = modelType
177 m.selectedModelType = modelType
221 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
178 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
222 }
179 }
223
180
224− if m.showClaudeAuthMethodChooser {
225− switch m.claudeAuthMethodChooser.State {
226− case claude.AuthMethodAPIKey:
227− askForApiKey()
228− case claude.AuthMethodOAuth2:
229− m.selectedModel = selectedItem
230− m.selectedModelType = modelType
231− m.showClaudeAuthMethodChooser = false
232− m.showClaudeOAuth2 = true
233− m.keyMap.isClaudeAuthChoiceHelp = false
234− m.keyMap.isClaudeOAuthHelp = true
235− }
236− return m, nil
237− }
238− if m.showClaudeOAuth2 {
239− m2, cmd2 := m.claudeOAuth2.ValidationConfirm()
240− m.claudeOAuth2 = m2.(*claude.OAuth2)
241− return m, cmd2
242− }
243 if m.isAPIKeyValid {
181 if m.isAPIKeyValid {
244 return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
182 return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
245 }
183 }
@@ -298,10 +236,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
298 )
236 )
299 }
237 }
300 switch selectedItem.Provider.ID {
238 switch selectedItem.Provider.ID {
301− case catwalk.InferenceProviderAnthropic:
302− m.showClaudeAuthMethodChooser = true
303− m.keyMap.isClaudeAuthChoiceHelp = true
304− return m, nil
305 case hyperp.Name:
239 case hyperp.Name:
306 m.showHyperDeviceFlow = true
240 m.showHyperDeviceFlow = true
307 m.selectedModel = selectedItem
241 m.selectedModel = selectedItem
@@ -327,9 +261,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
327 return m, nil
261 return m, nil
328 case key.Matches(msg, m.keyMap.Tab):
262 case key.Matches(msg, m.keyMap.Tab):
329 switch {
263 switch {
330− case m.showClaudeAuthMethodChooser:
331− m.claudeAuthMethodChooser.ToggleChoice()
332− return m, nil
333 case m.needsAPIKey:
264 case m.needsAPIKey:
334 u, cmd := m.apiKeyInput.Update(msg)
265 u, cmd := m.apiKeyInput.Update(msg)
335 m.apiKeyInput = u.(*APIKeyInput)
266 m.apiKeyInput = u.(*APIKeyInput)
@@ -355,12 +286,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
355 }
286 }
356 m.showCopilotDeviceFlow = false
287 m.showCopilotDeviceFlow = false
357 m.selectedModel = nil
288 m.selectedModel = nil
358− case m.showClaudeAuthMethodChooser:
359− m.claudeAuthMethodChooser.SetDefaults()
360− m.showClaudeAuthMethodChooser = false
361− m.keyMap.isClaudeAuthChoiceHelp = false
362− m.keyMap.isClaudeOAuthHelp = false
363− return m, nil
364 case m.needsAPIKey:
289 case m.needsAPIKey:
365 if m.isAPIKeyValid {
290 if m.isAPIKeyValid {
366 return m, nil
291 return m, nil
@@ -377,14 +302,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
377 }
302 }
378 default:
303 default:
379 switch {
304 switch {
380− case m.showClaudeAuthMethodChooser:
381− u, cmd := m.claudeAuthMethodChooser.Update(msg)
382− m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
383− return m, cmd
384− case m.showClaudeOAuth2:
385− u, cmd := m.claudeOAuth2.Update(msg)
386− m.claudeOAuth2 = u.(*claude.OAuth2)
387− return m, cmd
388 case m.needsAPIKey:
305 case m.needsAPIKey:
389 u, cmd := m.apiKeyInput.Update(msg)
306 u, cmd := m.apiKeyInput.Update(msg)
390 m.apiKeyInput = u.(*APIKeyInput)
307 m.apiKeyInput = u.(*APIKeyInput)
@@ -397,10 +314,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
397 }
314 }
398 case tea.PasteMsg:
315 case tea.PasteMsg:
399 switch {
316 switch {
400− case m.showClaudeOAuth2:
401− u, cmd := m.claudeOAuth2.Update(msg)
402− m.claudeOAuth2 = u.(*claude.OAuth2)
403− return m, cmd
404 case m.needsAPIKey:
317 case m.needsAPIKey:
405 u, cmd := m.apiKeyInput.Update(msg)
318 u, cmd := m.apiKeyInput.Update(msg)
406 m.apiKeyInput = u.(*APIKeyInput)
319 m.apiKeyInput = u.(*APIKeyInput)
@@ -433,10 +346,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
433 u, cmd := m.copilotDeviceFlow.Update(msg)
346 u, cmd := m.copilotDeviceFlow.Update(msg)
434 m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
347 m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
435 return m, cmd
348 return m, cmd
436− case m.showClaudeOAuth2:
437− u, cmd := m.claudeOAuth2.Update(msg)
438− m.claudeOAuth2 = u.(*claude.OAuth2)
439− return m, cmd
440 default:
349 default:
441 u, cmd := m.apiKeyInput.Update(msg)
350 u, cmd := m.apiKeyInput.Update(msg)
442 m.apiKeyInput = u.(*APIKeyInput)
351 m.apiKeyInput = u.(*APIKeyInput)
@@ -483,27 +392,6 @@ func (m *modelDialogCmp) View() string {
483 m.keyMap.isCopilotUnavailable = false
392 m.keyMap.isCopilotUnavailable = false
484
393
485 switch {
394 switch {
486− case m.showClaudeAuthMethodChooser:
487− chooserView := m.claudeAuthMethodChooser.View()
488− content := lipgloss.JoinVertical(
489− lipgloss.Left,
490− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
491− chooserView,
492− "",
493− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
494− )
495− return m.style().Render(content)
496− case m.showClaudeOAuth2:
497− m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL
498− oauth2View := m.claudeOAuth2.View()
499− content := lipgloss.JoinVertical(
500− lipgloss.Left,
501− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
502− oauth2View,
503− "",
504− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
505− )
506− return m.style().Render(content)
507 case m.needsAPIKey:
395 case m.needsAPIKey:
508 // Show API key input
396 // Show API key input
509 m.keyMap.isAPIKeyHelp = true
397 m.keyMap.isAPIKeyHelp = true
@@ -540,16 +428,6 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor {
540 if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
428 if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
541 return m.copilotDeviceFlow.Cursor()
429 return m.copilotDeviceFlow.Cursor()
542 }
430 }
543− if m.showClaudeAuthMethodChooser {
544− return nil
545− }
546− if m.showClaudeOAuth2 {
547− if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
548− cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
549− return m.moveCursor(cursor)
550− }
551− return nil
552− }
553 if m.needsAPIKey {
431 if m.needsAPIKey {
554 cursor := m.apiKeyInput.Cursor()
432 cursor := m.apiKeyInput.Cursor()
555 if cursor != nil {
433 if cursor != nil {
internal/tui/page/chat/chat.go+2−50
@@ -29,7 +29,6 @@ import (
29 "github.com/charmbracelet/crush/internal/tui/components/core"
29 "github.com/charmbracelet/crush/internal/tui/components/core"
30 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
30 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32− "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
@@ -337,9 +336,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
337 cmds = append(cmds, cmd)
336 cmds = append(cmds, cmd)
338 return p, tea.Batch(cmds...)
337 return p, tea.Batch(cmds...)
339
338
340− case claude.ValidationCompletedMsg,
341− claude.AuthenticationCompleteMsg,
342− hyper.DeviceFlowCompletedMsg,
339+ case hyper.DeviceFlowCompletedMsg,
343 hyper.DeviceAuthInitiatedMsg,
340 hyper.DeviceAuthInitiatedMsg,
344 hyper.DeviceFlowErrorMsg,
341 hyper.DeviceFlowErrorMsg,
345 copilot.DeviceAuthInitiatedMsg,
342 copilot.DeviceAuthInitiatedMsg,
@@ -1037,53 +1034,8 @@ func (p *chatPage) Help() help.KeyMap {
1037 var shortList []key.Binding
1034 var shortList []key.Binding
1038 var fullList [][]key.Binding
1035 var fullList [][]key.Binding
1039 switch {
1036 switch {
1040− case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
1037+ case p.isOnboarding:
1041− shortList = append(shortList,
1042− // Choose auth method
1043− key.NewBinding(
1044− key.WithKeys("left", "right", "tab"),
1045− key.WithHelp("←→/tab", "choose"),
1046− ),
1047− // Accept selection
1048− key.NewBinding(
1049− key.WithKeys("enter"),
1050− key.WithHelp("enter", "accept"),
1051− ),
1052− // Go back
1053− key.NewBinding(
1054− key.WithKeys("esc", "alt+esc"),
1055− key.WithHelp("esc", "back"),
1056− ),
1057− // Quit
1058− key.NewBinding(
1059− key.WithKeys("ctrl+c"),
1060− key.WithHelp("ctrl+c", "quit"),
1061− ),
1062− )
1063− // keep them the same
1064− for _, v := range shortList {
1065− fullList = append(fullList, []key.Binding{v})
1066− }
1067− case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1068 switch {
1038 switch {
1069− case p.splash.IsClaudeOAuthURLState():
1070− shortList = append(shortList,
1071− key.NewBinding(
1072− key.WithKeys("enter"),
1073− key.WithHelp("enter", "open"),
1074− ),
1075− key.NewBinding(
1076− key.WithKeys("c"),
1077− key.WithHelp("c", "copy url"),
1078− ),
1079− )
1080− case p.splash.IsClaudeOAuthComplete():
1081− shortList = append(shortList,
1082− key.NewBinding(
1083− key.WithKeys("enter"),
1084− key.WithHelp("enter", "continue"),
1085− ),
1086− )
1087 case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1039 case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1088 shortList = append(shortList,
1040 shortList = append(shortList,
1089 key.NewBinding(
1041 key.NewBinding(