on main help sign in
Merge remote-tracking branch 'origin/list' into tool_improvements
commit9c48f3226cb82bca1e3d3a2ea599c43762c280f7 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 25 Jul 2025, 15:15 (1 year ago) parent29f1de20770b12
15 files changed+556−109
README.md+40−4 internal/config/config.go+3 internal/config/load.go+1−1 internal/config/load_test.go+29 internal/config/resolve.go+100−14 internal/config/resolve_test.go+166−11 internal/llm/provider/anthropic.go+45−10 internal/llm/provider/gemini.go+10−2 internal/llm/provider/openai.go+5−1 internal/llm/provider/provider.go+28−16 internal/tui/components/chat/editor/editor.go+42−15 internal/tui/components/completions/completions.go+77−23 internal/tui/exp/list/items.go+5 internal/tui/page/chat/chat.go+3−1 internal/tui/tui.go+2−11
README.md+40−4
@@ -135,7 +135,6 @@ Crush supports Model Context Protocol (MCP) servers through three transport type
135135### Logging
136136
137137Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
138−
139138```bash
140139# Run with debug logging
141140crush -d
@@ -186,16 +185,21 @@ The `allowed_tools` array accepts:
186185
187186You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188187
189−### OpenAI-Compatible APIs
188+### Custom Providers
189+
190+Crush supports custom provider configurations for both OpenAI-compatible and Anthropic-compatible APIs.
191+
192+#### OpenAI-Compatible APIs
190193
191−Crush supports all OpenAI-compatible APIs. Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
194+Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
192195
193196```json
194197{
195198 "providers": {
196199 "deepseek": {
197− "provider_type": "openai",
200+ "type": "openai",
198201 "base_url": "https://api.deepseek.com/v1",
202+ "api_key": "$DEEPSEEK_API_KEY",
199203 "models": [
200204 {
201205 "id": "deepseek-chat",
@@ -213,6 +217,38 @@ Crush supports all OpenAI-compatible APIs. Here's an example configuration for D
213217}
214218```
215219
220+#### Anthropic-Compatible APIs
221+
222+You can also configure custom Anthropic-compatible providers:
223+
224+```json
225+{
226+ "providers": {
227+ "custom-anthropic": {
228+ "type": "anthropic",
229+ "base_url": "https://api.anthropic.com/v1",
230+ "api_key": "$ANTHROPIC_API_KEY",
231+ "extra_headers": {
232+ "anthropic-version": "2023-06-01"
233+ },
234+ "models": [
235+ {
236+ "id": "claude-3-sonnet",
237+ "model": "Claude 3 Sonnet",
238+ "cost_per_1m_in": 3000,
239+ "cost_per_1m_out": 15000,
240+ "cost_per_1m_in_cached": 300,
241+ "cost_per_1m_out_cached": 15000,
242+ "context_window": 200000,
243+ "default_max_tokens": 4096,
244+ "supports_attachments": true
245+ }
246+ ]
247+ }
248+ }
249+}
250+```
251+
216252## Whatcha think?
217253
218254We’d love to hear your thoughts on this project. Feel free to drop us a note!
internal/config/config.go+3
@@ -77,6 +77,9 @@ type ProviderConfig struct {
7777 // Marks the provider as disabled.
7878 Disable bool `json:"disable,omitempty"`
7979
80+ // Custom system prompt prefix.
81+ SystemPromptPrefix string `json:"system_prompt_prefix,omitempty"`
82+
8083 // Extra headers to send with each request to the provider.
8184 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
8285 // Extra body
internal/config/load.go+1−1
@@ -232,7 +232,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
232232 c.Providers.Del(id)
233233 continue
234234 }
235− if providerConfig.Type != catwalk.TypeOpenAI {
235+ if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic {
236236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
237237 c.Providers.Del(id)
238238 continue
internal/config/load_test.go+29
@@ -613,6 +613,35 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
613613 assert.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
614614 })
615615
616+ t.Run("custom anthropic provider is supported", func(t *testing.T) {
617+ cfg := &Config{
618+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
619+ "custom-anthropic": {
620+ APIKey: "test-key",
621+ BaseURL: "https://api.anthropic.com/v1",
622+ Type: catwalk.TypeAnthropic,
623+ Models: []catwalk.Model{{
624+ ID: "claude-3-sonnet",
625+ }},
626+ },
627+ }),
628+ }
629+ cfg.setDefaults("/tmp")
630+
631+ env := env.NewFromMap(map[string]string{})
632+ resolver := NewEnvironmentVariableResolver(env)
633+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
634+ assert.NoError(t, err)
635+
636+ assert.Equal(t, cfg.Providers.Len(), 1)
637+ customProvider, exists := cfg.Providers.Get("custom-anthropic")
638+ assert.True(t, exists)
639+ assert.Equal(t, "custom-anthropic", customProvider.ID)
640+ assert.Equal(t, "test-key", customProvider.APIKey)
641+ assert.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
642+ assert.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
643+ })
644+
616645 t.Run("disabled custom provider is removed", func(t *testing.T) {
617646 cfg := &Config{
618647 Providers: csync.NewMapFrom(map[string]ProviderConfig{
internal/config/resolve.go+100−14
@@ -35,34 +35,120 @@ func NewShellVariableResolver(env env.Env) VariableResolver {
3535}
3636
3737// ResolveValue is a method for resolving values, such as environment variables.
38−// it will expect strings that start with `$` to be resolved as environment variables or shell commands.
39−// if the string does not start with `$`, it will return the string as is.
38+// it will resolve shell-like variable substitution anywhere in the string, including:
39+// - $(command) for command substitution
40+// - $VAR or ${VAR} for environment variables
4041func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
41− if !strings.HasPrefix(value, "$") {
42+ // Special case: lone $ is an error (backward compatibility)
43+ if value == "$" {
44+ return "", fmt.Errorf("invalid value format: %s", value)
45+ }
46+
47+ // If no $ found, return as-is
48+ if !strings.Contains(value, "$") {
4249 return value, nil
4350 }
4451
45− if strings.HasPrefix(value, "$(") && strings.HasSuffix(value, ")") {
46− command := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
52+ result := value
53+
54+ // Handle command substitution: $(command)
55+ for {
56+ start := strings.Index(result, "$(")
57+ if start == -1 {
58+ break
59+ }
60+
61+ // Find matching closing parenthesis
62+ depth := 0
63+ end := -1
64+ for i := start + 2; i < len(result); i++ {
65+ if result[i] == '(' {
66+ depth++
67+ } else if result[i] == ')' {
68+ if depth == 0 {
69+ end = i
70+ break
71+ }
72+ depth--
73+ }
74+ }
75+
76+ if end == -1 {
77+ return "", fmt.Errorf("unmatched $( in value: %s", value)
78+ }
79+
80+ command := result[start+2 : end]
4781 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
48− defer cancel()
4982
5083 stdout, _, err := r.shell.Exec(ctx, command)
84+ cancel()
5185 if err != nil {
52− return "", fmt.Errorf("command execution failed: %w", err)
86+ return "", fmt.Errorf("command execution failed for '%s': %w", command, err)
5387 }
54− return strings.TrimSpace(stdout), nil
88+
89+ // Replace the $(command) with the output
90+ replacement := strings.TrimSpace(stdout)
91+ result = result[:start] + replacement + result[end+1:]
5592 }
5693
57− if after, ok := strings.CutPrefix(value, "$"); ok {
58− varName := after
59− value = r.env.Get(varName)
60− if value == "" {
94+ // Handle environment variables: $VAR and ${VAR}
95+ searchStart := 0
96+ for {
97+ start := strings.Index(result[searchStart:], "$")
98+ if start == -1 {
99+ break
100+ }
101+ start += searchStart // Adjust for the offset
102+
103+ // Skip if this is part of $( which we already handled
104+ if start+1 < len(result) && result[start+1] == '(' {
105+ // Skip past this $(...)
106+ searchStart = start + 1
107+ continue
108+ }
109+ var varName string
110+ var end int
111+
112+ if start+1 < len(result) && result[start+1] == '{' {
113+ // Handle ${VAR} format
114+ closeIdx := strings.Index(result[start+2:], "}")
115+ if closeIdx == -1 {
116+ return "", fmt.Errorf("unmatched ${ in value: %s", value)
117+ }
118+ varName = result[start+2 : start+2+closeIdx]
119+ end = start + 2 + closeIdx + 1
120+ } else {
121+ // Handle $VAR format - variable names must start with letter or underscore
122+ if start+1 >= len(result) {
123+ return "", fmt.Errorf("incomplete variable reference at end of string: %s", value)
124+ }
125+
126+ if result[start+1] != '_' &&
127+ (result[start+1] < 'a' || result[start+1] > 'z') &&
128+ (result[start+1] < 'A' || result[start+1] > 'Z') {
129+ return "", fmt.Errorf("invalid variable name starting with '%c' in: %s", result[start+1], value)
130+ }
131+
132+ end = start + 1
133+ for end < len(result) && (result[end] == '_' ||
134+ (result[end] >= 'a' && result[end] <= 'z') ||
135+ (result[end] >= 'A' && result[end] <= 'Z') ||
136+ (result[end] >= '0' && result[end] <= '9')) {
137+ end++
138+ }
139+ varName = result[start+1 : end]
140+ }
141+
142+ envValue := r.env.Get(varName)
143+ if envValue == "" {
61144 return "", fmt.Errorf("environment variable %q not set", varName)
62145 }
63− return value, nil
146+
147+ result = result[:start] + envValue + result[end:]
148+ searchStart = start + len(envValue) // Continue searching after the replacement
64149 }
65− return "", fmt.Errorf("invalid value format: %s", value)
150+
151+ return result, nil
66152}
67153
68154type environmentVariableResolver struct {
internal/config/resolve_test.go+166−11
@@ -47,17 +47,7 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
4747 envVars: map[string]string{},
4848 expectError: true,
4949 },
50− {
51− name: "shell command execution",
52− value: "$(echo hello)",
53− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
54− if command == "echo hello" {
55− return "hello\n", "", nil
56− }
57− return "", "", errors.New("unexpected command")
58− },
59− expected: "hello",
60− },
50+
6151 {
6252 name: "shell command with whitespace trimming",
6353 value: "$(echo ' spaced ')",
@@ -104,6 +94,171 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
10494 }
10595}
10696
97+func TestShellVariableResolver_EnhancedResolveValue(t *testing.T) {
98+ tests := []struct {
99+ name string
100+ value string
101+ envVars map[string]string
102+ shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
103+ expected string
104+ expectError bool
105+ }{
106+ {
107+ name: "command substitution within string",
108+ value: "Bearer $(echo token123)",
109+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
110+ if command == "echo token123" {
111+ return "token123\n", "", nil
112+ }
113+ return "", "", errors.New("unexpected command")
114+ },
115+ expected: "Bearer token123",
116+ },
117+ {
118+ name: "environment variable within string",
119+ value: "Bearer $TOKEN",
120+ envVars: map[string]string{"TOKEN": "sk-ant-123"},
121+ expected: "Bearer sk-ant-123",
122+ },
123+ {
124+ name: "environment variable with braces within string",
125+ value: "Bearer ${TOKEN}",
126+ envVars: map[string]string{"TOKEN": "sk-ant-456"},
127+ expected: "Bearer sk-ant-456",
128+ },
129+ {
130+ name: "mixed command and environment substitution",
131+ value: "$USER-$(date +%Y)-$HOST",
132+ envVars: map[string]string{
133+ "USER": "testuser",
134+ "HOST": "localhost",
135+ },
136+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
137+ if command == "date +%Y" {
138+ return "2024\n", "", nil
139+ }
140+ return "", "", errors.New("unexpected command")
141+ },
142+ expected: "testuser-2024-localhost",
143+ },
144+ {
145+ name: "multiple command substitutions",
146+ value: "$(echo hello) $(echo world)",
147+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
148+ switch command {
149+ case "echo hello":
150+ return "hello\n", "", nil
151+ case "echo world":
152+ return "world\n", "", nil
153+ }
154+ return "", "", errors.New("unexpected command")
155+ },
156+ expected: "hello world",
157+ },
158+ {
159+ name: "nested parentheses in command",
160+ value: "$(echo $(echo inner))",
161+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
162+ if command == "echo $(echo inner)" {
163+ return "nested\n", "", nil
164+ }
165+ return "", "", errors.New("unexpected command")
166+ },
167+ expected: "nested",
168+ },
169+ {
170+ name: "lone dollar with non-variable chars",
171+ value: "prefix$123suffix", // Numbers can't start variable names
172+ expectError: true,
173+ },
174+ {
175+ name: "dollar with special chars",
176+ value: "a$@b$#c", // Special chars aren't valid in variable names
177+ expectError: true,
178+ },
179+ {
180+ name: "empty environment variable substitution",
181+ value: "Bearer $EMPTY_VAR",
182+ envVars: map[string]string{},
183+ expectError: true,
184+ },
185+ {
186+ name: "unmatched command substitution opening",
187+ value: "Bearer $(echo test",
188+ expectError: true,
189+ },
190+ {
191+ name: "unmatched environment variable braces",
192+ value: "Bearer ${TOKEN",
193+ expectError: true,
194+ },
195+ {
196+ name: "command substitution with error",
197+ value: "Bearer $(false)",
198+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
199+ return "", "", errors.New("command failed")
200+ },
201+ expectError: true,
202+ },
203+ {
204+ name: "complex real-world example",
205+ value: "Bearer $(cat /tmp/token.txt | base64 -w 0)",
206+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
207+ if command == "cat /tmp/token.txt | base64 -w 0" {
208+ return "c2stYW50LXRlc3Q=\n", "", nil
209+ }
210+ return "", "", errors.New("unexpected command")
211+ },
212+ expected: "Bearer c2stYW50LXRlc3Q=",
213+ },
214+ {
215+ name: "environment variable with underscores and numbers",
216+ value: "Bearer $API_KEY_V2",
217+ envVars: map[string]string{"API_KEY_V2": "sk-test-123"},
218+ expected: "Bearer sk-test-123",
219+ },
220+ {
221+ name: "no substitution needed",
222+ value: "Bearer sk-ant-static-token",
223+ expected: "Bearer sk-ant-static-token",
224+ },
225+ {
226+ name: "incomplete variable at end",
227+ value: "Bearer $",
228+ expectError: true,
229+ },
230+ {
231+ name: "variable with invalid character",
232+ value: "Bearer $VAR-NAME", // Hyphen not allowed in variable names
233+ expectError: true,
234+ },
235+ {
236+ name: "multiple invalid variables",
237+ value: "$1$2$3",
238+ expectError: true,
239+ },
240+ }
241+
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ testEnv := env.NewFromMap(tt.envVars)
245+ resolver := &shellVariableResolver{
246+ shell: &mockShell{execFunc: tt.shellFunc},
247+ env: testEnv,
248+ }
249+
250+ result, err := resolver.ResolveValue(tt.value)
251+
252+ if tt.expectError {
253+ assert.Error(t, err)
254+ } else {
255+ assert.NoError(t, err)
256+ assert.Equal(t, tt.expected, result)
257+ }
258+ })
259+ }
260+}
261+
107262func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
108263 tests := []struct {
109264 name string
internal/llm/provider/anthropic.go+45−10
@@ -39,8 +39,30 @@ func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicCl
3939
4040func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
4141 anthropicClientOptions := []option.RequestOption{}
42− if opts.apiKey != "" {
43− anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
42+
43+ // Check if Authorization header is provided in extra headers
44+ hasBearerAuth := false
45+ if opts.extraHeaders != nil {
46+ for key := range opts.extraHeaders {
47+ if strings.ToLower(key) == "authorization" {
48+ hasBearerAuth = true
49+ break
50+ }
51+ }
52+ }
53+
54+ isBearerToken := strings.HasPrefix(opts.apiKey, "Bearer ")
55+
56+ if opts.apiKey != "" && !hasBearerAuth {
57+ if isBearerToken {
58+ slog.Debug("API key starts with 'Bearer ', using as Authorization header")
59+ anthropicClientOptions = append(anthropicClientOptions, option.WithHeader("Authorization", opts.apiKey))
60+ } else {
61+ // Use standard X-Api-Key header
62+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
63+ }
64+ } else if hasBearerAuth {
65+ slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
4466 }
4567 if useBedrock {
4668 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
@@ -200,6 +222,25 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
200222 maxTokens = int64(a.adjustedMaxTokens)
201223 }
202224
225+ systemBlocks := []anthropic.TextBlockParam{}
226+
227+ // Add custom system prompt prefix if configured
228+ if a.providerOptions.systemPromptPrefix != "" {
229+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
230+ Text: a.providerOptions.systemPromptPrefix,
231+ CacheControl: anthropic.CacheControlEphemeralParam{
232+ Type: "ephemeral",
233+ },
234+ })
235+ }
236+
237+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
238+ Text: a.providerOptions.systemMessage,
239+ CacheControl: anthropic.CacheControlEphemeralParam{
240+ Type: "ephemeral",
241+ },
242+ })
243+
203244 return anthropic.MessageNewParams{
204245 Model: anthropic.Model(model.ID),
205246 MaxTokens: maxTokens,
@@ -207,14 +248,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
207248 Messages: messages,
208249 Tools: tools,
209250 Thinking: thinkingParam,
210− System: []anthropic.TextBlockParam{
211− {
212− Text: a.providerOptions.systemMessage,
213− CacheControl: anthropic.CacheControlEphemeralParam{
214− Type: "ephemeral",
215− },
216− },
217− },
251+ System: systemBlocks,
218252 }
219253}
220254
@@ -393,6 +427,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
393427 close(eventChan)
394428 return
395429 }
430+
396431 // If there is an error we are going to see if we can retry the call
397432 retry, after, retryErr := a.shouldRetry(attempts, err)
398433 if retryErr != nil {
internal/llm/provider/gemini.go+10−2
@@ -180,12 +180,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
180180 if modelConfig.MaxTokens > 0 {
181181 maxTokens = modelConfig.MaxTokens
182182 }
183+ systemMessage := g.providerOptions.systemMessage
184+ if g.providerOptions.systemPromptPrefix != "" {
185+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
186+ }
183187 history := geminiMessages[:len(geminiMessages)-1] // All but last message
184188 lastMsg := geminiMessages[len(geminiMessages)-1]
185189 config := &genai.GenerateContentConfig{
186190 MaxOutputTokens: int32(maxTokens),
187191 SystemInstruction: &genai.Content{
188− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
192+ Parts: []*genai.Part{{Text: systemMessage}},
189193 },
190194 }
191195 config.Tools = g.convertTools(tools)
@@ -280,12 +284,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
280284 if g.providerOptions.maxTokens > 0 {
281285 maxTokens = g.providerOptions.maxTokens
282286 }
287+ systemMessage := g.providerOptions.systemMessage
288+ if g.providerOptions.systemPromptPrefix != "" {
289+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
290+ }
283291 history := geminiMessages[:len(geminiMessages)-1] // All but last message
284292 lastMsg := geminiMessages[len(geminiMessages)-1]
285293 config := &genai.GenerateContentConfig{
286294 MaxOutputTokens: int32(maxTokens),
287295 SystemInstruction: &genai.Content{
288− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
296+ Parts: []*genai.Part{{Text: systemMessage}},
289297 },
290298 }
291299 config.Tools = g.convertTools(tools)
internal/llm/provider/openai.go+5−1
@@ -57,7 +57,11 @@ func createOpenAIClient(opts providerClientOptions) openai.Client {
5757
5858func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
5959 // Add system message first
60− openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
60+ systemMessage := o.providerOptions.systemMessage
61+ if o.providerOptions.systemPromptPrefix != "" {
62+ systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
63+ }
64+ openaiMessages = append(openaiMessages, openai.SystemMessage(systemMessage))
6165
6266 for _, msg := range messages {
6367 switch msg.Role {
internal/llm/provider/provider.go+28−16
@@ -61,17 +61,18 @@ type Provider interface {
6161}
6262
6363type providerClientOptions struct {
64− baseURL string
65− config config.ProviderConfig
66− apiKey string
67− modelType config.SelectedModelType
68− model func(config.SelectedModelType) catwalk.Model
69− disableCache bool
70− systemMessage string
71− maxTokens int64
72− extraHeaders map[string]string
73− extraBody map[string]any
74− extraParams map[string]string
64+ baseURL string
65+ config config.ProviderConfig
66+ apiKey string
67+ modelType config.SelectedModelType
68+ model func(config.SelectedModelType) catwalk.Model
69+ disableCache bool
70+ systemMessage string
71+ systemPromptPrefix string
72+ maxTokens int64
73+ extraHeaders map[string]string
74+ extraBody map[string]any
75+ extraParams map[string]string
7576}
7677
7778type ProviderClientOption func(*providerClientOptions)
@@ -143,12 +144,23 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
143144 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
144145 }
145146
147+ // Resolve extra headers
148+ resolvedExtraHeaders := make(map[string]string)
149+ for key, value := range cfg.ExtraHeaders {
150+ resolvedValue, err := config.Get().Resolve(value)
151+ if err != nil {
152+ return nil, fmt.Errorf("failed to resolve extra header %s for provider %s: %w", key, cfg.ID, err)
153+ }
154+ resolvedExtraHeaders[key] = resolvedValue
155+ }
156+
146157 clientOptions := providerClientOptions{
147− baseURL: cfg.BaseURL,
148− config: cfg,
149− apiKey: resolvedAPIKey,
150− extraHeaders: cfg.ExtraHeaders,
151− extraBody: cfg.ExtraBody,
158+ baseURL: cfg.BaseURL,
159+ config: cfg,
160+ apiKey: resolvedAPIKey,
161+ extraHeaders: resolvedExtraHeaders,
162+ extraBody: cfg.ExtraBody,
163+ systemPromptPrefix: cfg.SystemPromptPrefix,
152164 model: func(tp config.SelectedModelType) catwalk.Model {
153165 return *config.Get().GetModelByType(tp)
154166 },
internal/tui/components/chat/editor/editor.go+42−15
@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
161161 )
162162}
163163
164+func (m *editorCmp) repositionCompletions() tea.Msg {
165+ x, y := m.completionsPosition()
166+ return completions.RepositionCompletionsMsg{X: x, Y: y}
167+}
168+
164169func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165170 var cmd tea.Cmd
166171 var cmds []tea.Cmd
167172 switch msg := msg.(type) {
173+ case tea.WindowSizeMsg:
174+ return m, m.repositionCompletions
168175 case filepicker.FilePickedMsg:
169176 if len(m.attachments) >= maxAttachments {
170177 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
@@ -182,32 +189,37 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182189 return m, nil
183190 }
184191 if item, ok := msg.Value.(FileCompletionItem); ok {
192+ word := m.textarea.Word()
185193 // If the selected item is a file, insert its path into the textarea
186194 value := m.textarea.Value()
187− value = value[:m.completionsStartIndex]
188− value += item.Path
195+ value = value[:m.completionsStartIndex] + // Remove the current query
196+ item.Path + // Insert the file path
197+ value[m.completionsStartIndex+len(word):] // Append the rest of the value
198+ // XXX: This will always move the cursor to the end of the textarea.
189199 m.textarea.SetValue(value)
200+ m.textarea.MoveToEnd()
190201 if !msg.Insert {
191202 m.isCompletionsOpen = false
192203 m.currentQuery = ""
193204 m.completionsStartIndex = 0
194205 }
195− return m, nil
196206 }
197207 case openEditorMsg:
198208 m.textarea.SetValue(msg.Text)
199209 m.textarea.MoveToEnd()
200210 case tea.KeyPressMsg:
211+ cur := m.textarea.Cursor()
212+ curIdx := m.textarea.Width()*cur.Y + cur.X
201213 switch {
202214 // Completions
203215 case msg.String() == "/" && !m.isCompletionsOpen &&
204− // only show if beginning of prompt, or if previous char is a space:
205− (len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
216+ // only show if beginning of prompt, or if previous char is a space or newline:
217+ (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
206218 m.isCompletionsOpen = true
207219 m.currentQuery = ""
208− m.completionsStartIndex = len(m.textarea.Value())
220+ m.completionsStartIndex = curIdx
209221 cmds = append(cmds, m.startCompletions)
210− case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
222+ case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
211223 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
212224 }
213225 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244256 }
245257 if key.Matches(msg, m.keyMap.Newline) {
246258 m.textarea.InsertRune('\n')
259+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
247260 }
248261 // Handle Enter key
249262 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
@@ -275,12 +288,18 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
275288 // XXX: wont' work if editing in the middle of the field.
276289 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
277290 m.currentQuery = word[1:]
291+ x, y := m.completionsPosition()
292+ x -= len(m.currentQuery)
278293 m.isCompletionsOpen = true
279− cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
280− Query: m.currentQuery,
281− Reopen: m.isCompletionsOpen,
282− }))
283− } else {
294+ cmds = append(cmds,
295+ util.CmdHandler(completions.FilterCompletionsMsg{
296+ Query: m.currentQuery,
297+ Reopen: m.isCompletionsOpen,
298+ X: x,
299+ Y: y,
300+ }),
301+ )
302+ } else if m.isCompletionsOpen {
284303 m.isCompletionsOpen = false
285304 m.currentQuery = ""
286305 m.completionsStartIndex = 0
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293312 return m, tea.Batch(cmds...)
294313}
295314
315+func (m *editorCmp) completionsPosition() (int, int) {
316+ cur := m.textarea.Cursor()
317+ if cur == nil {
318+ return m.x, m.y + 1 // adjust for padding
319+ }
320+ x := cur.X + m.x
321+ y := cur.Y + m.y + 1 // adjust for padding
322+ return x, y
323+}
324+
296325func (m *editorCmp) Cursor() *tea.Cursor {
297326 cursor := m.textarea.Cursor()
298327 if cursor != nil {
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
373402 })
374403 }
375404
376− cur := m.textarea.Cursor()
377− x := cur.X + m.x // adjust for padding
378− y := cur.Y + m.y + 1
405+ x, y := m.completionsPosition()
379406 return completions.OpenCompletionsMsg{
380407 Completions: completionItems,
381408 X: x,
internal/tui/components/completions/completions.go+77−23
@@ -27,6 +27,12 @@ type OpenCompletionsMsg struct {
2727type FilterCompletionsMsg struct {
2828 Query string // The query to filter completions
2929 Reopen bool
30+ X int // X position for the completions popup
31+ Y int // Y position for the completions popup
32+}
33+
34+type RepositionCompletionsMsg struct {
35+ X, Y int
3036}
3137
3238type CompletionsClosedMsg struct{}
@@ -53,18 +59,24 @@ type Completions interface {
5359type listModel = list.FilterableList[list.CompletionItem[any]]
5460
5561type completionsCmp struct {
56− width int
57− height int // Height of the completions component`
58− x int // X position for the completions popup
59− y int // Y position for the completions popup
60− open bool // Indicates if the completions are open
61− keyMap KeyMap
62+ wWidth int // The window width
63+ wHeight int // The window height
64+ width int
65+ lastWidth int
66+ height int // Height of the completions component`
67+ x, xorig int // X position for the completions popup
68+ y int // Y position for the completions popup
69+ open bool // Indicates if the completions are open
70+ keyMap KeyMap
6271
6372 list listModel
6473 query string // The current filter query
6574}
6675
67−const maxCompletionsWidth = 80 // Maximum width for the completions popup
76+const (
77+ maxCompletionsWidth = 80 // Maximum width for the completions popup
78+ minCompletionsWidth = 20 // Minimum width for the completions popup
79+)
6880
6981func New() Completions {
7082 completionsKeyMap := DefaultKeyMap()
@@ -88,7 +100,7 @@ func New() Completions {
88100 )
89101 return &completionsCmp{
90102 width: 0,
91− height: 0,
103+ height: maxCompletionsHeight,
92104 list: l,
93105 query: "",
94106 keyMap: completionsKeyMap,
@@ -107,8 +119,7 @@ func (c *completionsCmp) Init() tea.Cmd {
107119func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108120 switch msg := msg.(type) {
109121 case tea.WindowSizeMsg:
110− c.width = min(msg.Width-c.x, maxCompletionsWidth)
111− c.height = min(msg.Height-c.y, 15)
122+ c.wWidth, c.wHeight = msg.Width, msg.Height
112123 return c, nil
113124 case tea.KeyPressMsg:
114125 switch {
@@ -129,7 +140,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129140 selectedItem := *s
130141 c.list.SetSelected(selectedItem.ID())
131142 return c, util.CmdHandler(SelectCompletionMsg{
132− Value: selectedItem,
143+ Value: selectedItem.Value(),
133144 Insert: true,
134145 })
135146 case key.Matches(msg, c.keyMap.DownInsert):
@@ -140,7 +151,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140151 selectedItem := *s
141152 c.list.SetSelected(selectedItem.ID())
142153 return c, util.CmdHandler(SelectCompletionMsg{
143− Value: selectedItem,
154+ Value: selectedItem.Value(),
144155 Insert: true,
145156 })
146157 case key.Matches(msg, c.keyMap.Select):
@@ -151,18 +162,21 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151162 selectedItem := *s
152163 c.open = false // Close completions after selection
153164 return c, util.CmdHandler(SelectCompletionMsg{
154− Value: selectedItem,
165+ Value: selectedItem.Value(),
155166 })
156167 case key.Matches(msg, c.keyMap.Cancel):
157168 return c, util.CmdHandler(CloseCompletionsMsg{})
158169 }
170+ case RepositionCompletionsMsg:
171+ c.x, c.y = msg.X, msg.Y
172+ c.adjustPosition()
159173 case CloseCompletionsMsg:
160174 c.open = false
161175 return c, util.CmdHandler(CompletionsClosedMsg{})
162176 case OpenCompletionsMsg:
163177 c.open = true
164178 c.query = ""
165− c.x = msg.X
179+ c.x, c.xorig = msg.X, msg.X
166180 c.y = msg.Y
167181 items := []list.CompletionItem[any]{}
168182 t := styles.CurrentTheme()
@@ -174,10 +188,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174188 )
175189 items = append(items, item)
176190 }
177− c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
191+ width := listWidth(items)
192+ if len(items) == 0 {
193+ width = listWidth(c.list.Items())
194+ }
195+ if c.x+width >= c.wWidth {
196+ c.x = c.wWidth - width - 1
197+ }
198+ c.width = width
199+ c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
178200 return c, tea.Batch(
179− c.list.SetSize(c.width, c.height),
180201 c.list.SetItems(items),
202+ c.list.SetSize(c.width, c.height),
181203 util.CmdHandler(CompletionsOpenedMsg{}),
182204 )
183205 case FilterCompletionsMsg:
@@ -201,8 +223,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
201223 c.query = msg.Query
202224 var cmds []tea.Cmd
203225 cmds = append(cmds, c.list.Filter(msg.Query))
204− itemsLen := len(c.list.Items())
205− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
226+ items := c.list.Items()
227+ itemsLen := len(items)
228+ c.xorig = msg.X
229+ c.x, c.y = msg.X, msg.Y
230+ c.adjustPosition()
206231 cmds = append(cmds, c.list.SetSize(c.width, c.height))
207232 if itemsLen == 0 {
208233 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -215,21 +240,50 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
215240 return c, nil
216241}
217242
243+func (c *completionsCmp) adjustPosition() {
244+ items := c.list.Items()
245+ itemsLen := len(items)
246+ width := listWidth(items)
247+ c.lastWidth = c.width
248+ if c.x < 0 || width < c.lastWidth {
249+ c.x = c.xorig
250+ } else if c.x+width >= c.wWidth {
251+ c.x = c.wWidth - width - 1
252+ }
253+ c.width = width
254+ c.height = max(min(maxCompletionsHeight, itemsLen), 1)
255+}
256+
218257// View implements Completions.
219258func (c *completionsCmp) View() string {
220259 if !c.open || len(c.list.Items()) == 0 {
221260 return ""
222261 }
223262
224− return c.style().Render(c.list.View())
225−}
226−
227−func (c *completionsCmp) style() lipgloss.Style {
228263 t := styles.CurrentTheme()
229− return t.S().Base.
264+ style := t.S().Base.
230265 Width(c.width).
231266 Height(c.height).
232267 Background(t.BgSubtle)
268+
269+ return style.Render(c.list.View())
270+}
271+
272+// listWidth returns the width of the last 10 items in the list, which is used
273+// to determine the width of the completions popup.
274+// Note this only works for [completionItemCmp] items.
275+func listWidth(items []list.CompletionItem[any]) int {
276+ var width int
277+ if len(items) == 0 {
278+ return width
279+ }
280+
281+ for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
282+ itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
283+ width = max(width, itemWidth)
284+ }
285+
286+ return width
233287}
234288
235289func (c *completionsCmp) Open() bool {
internal/tui/exp/list/items.go+5
@@ -23,6 +23,7 @@ type CompletionItem[T any] interface {
2323 layout.Sizeable
2424 HasMatchIndexes
2525 Value() T
26+ Text() string
2627}
2728
2829type completionItemCmp[T any] struct {
@@ -312,6 +313,10 @@ func (c *completionItemCmp[T]) ID() string {
312313 return c.id
313314}
314315
316+func (c *completionItemCmp[T]) Text() string {
317+ return c.text
318+}
319+
315320type ItemSection interface {
316321 Item
317322 layout.Sizeable
internal/tui/page/chat/chat.go+3−1
@@ -173,7 +173,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
173173 }
174174 return p, nil
175175 case tea.WindowSizeMsg:
176− return p, p.SetSize(msg.Width, msg.Height)
176+ u, cmd := p.editor.Update(msg)
177+ p.editor = u.(editor.Editor)
178+ return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
177179 case CancelTimerExpiredMsg:
178180 p.isCanceling = false
179181 return p, nil
internal/tui/tui.go+2−11
@@ -111,19 +111,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111111 return a, a.handleWindowResize(msg.Width, msg.Height)
112112
113113 // Completions messages
114− case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
114+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
115+ completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
115116 u, completionCmd := a.completions.Update(msg)
116117 a.completions = u.(completions.Completions)
117− switch msg := msg.(type) {
118− case completions.OpenCompletionsMsg:
119− x, _ := a.completions.Position()
120− if a.completions.Width()+x >= a.wWidth {
121− // Adjust X position to fit in the window.
122− msg.X = a.wWidth - a.completions.Width() - 1
123− u, completionCmd = a.completions.Update(msg)
124− a.completions = u.(completions.Completions)
125− }
126− }
127118 return a, completionCmd
128119
129120 // Dialog messages