on main help sign in
Merge remote-tracking branch 'origin/list' into tool_improvements
9c48f32Kujtim Hoxha
README.md+40−4
@@ -135,7 +135,6 @@ Crush supports Model Context Protocol (MCP) servers through three transport type
135### Logging
135### Logging
136
136
137Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
137Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
138−
139```bash
138```bash
140# Run with debug logging
139# Run with debug logging
141crush -d
140crush -d
@@ -186,16 +185,21 @@ The `allowed_tools` array accepts:
186
185
187You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
186You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188
187
188+### Custom Providers
189+
190+Crush supports custom provider configurations for both OpenAI-compatible and Anthropic-compatible APIs.
191+
189−### OpenAI-Compatible APIs
192+#### OpenAI-Compatible APIs
190
193
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.
192
195
193```json
196```json
194{
197{
195 "providers": {
198 "providers": {
196 "deepseek": {
199 "deepseek": {
197− "provider_type": "openai",
200+ "type": "openai",
198 "base_url": "https://api.deepseek.com/v1",
201 "base_url": "https://api.deepseek.com/v1",
202+ "api_key": "$DEEPSEEK_API_KEY",
199 "models": [
203 "models": [
200 {
204 {
201 "id": "deepseek-chat",
205 "id": "deepseek-chat",
@@ -213,6 +217,38 @@ Crush supports all OpenAI-compatible APIs. Here's an example configuration for D
213}
217}
214```
218```
215
219
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+
216## Whatcha think?
252## Whatcha think?
217
253
218We’d love to hear your thoughts on this project. Feel free to drop us a note!
254We’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 {
77 // Marks the provider as disabled.
77 // Marks the provider as disabled.
78 Disable bool `json:"disable,omitempty"`
78 Disable bool `json:"disable,omitempty"`
79
79
80+ // Custom system prompt prefix.
81+ SystemPromptPrefix string `json:"system_prompt_prefix,omitempty"`
82+
80 // Extra headers to send with each request to the provider.
83 // Extra headers to send with each request to the provider.
81 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
84 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
82 // Extra body
85 // Extra body
internal/config/load.go+1−1
@@ -232,7 +232,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
232 c.Providers.Del(id)
232 c.Providers.Del(id)
233 continue
233 continue
234 }
234 }
235− if providerConfig.Type != catwalk.TypeOpenAI {
235+ if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic {
236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
237 c.Providers.Del(id)
237 c.Providers.Del(id)
238 continue
238 continue
internal/config/load_test.go+29
@@ -613,6 +613,35 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
613 assert.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
613 assert.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
614 })
614 })
615
615
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+
616 t.Run("disabled custom provider is removed", func(t *testing.T) {
645 t.Run("disabled custom provider is removed", func(t *testing.T) {
617 cfg := &Config{
646 cfg := &Config{
618 Providers: csync.NewMapFrom(map[string]ProviderConfig{
647 Providers: csync.NewMapFrom(map[string]ProviderConfig{
internal/config/resolve.go+100−14
@@ -35,34 +35,120 @@ func NewShellVariableResolver(env env.Env) VariableResolver {
35}
35}
36
36
37// ResolveValue is a method for resolving values, such as environment variables.
37// 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.
38+// it will resolve shell-like variable substitution anywhere in the string, including:
39−// if the string does not start with `$`, it will return the string as is.
39+// - $(command) for command substitution
40+// - $VAR or ${VAR} for environment variables
40func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
41func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
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
41− if !strings.HasPrefix(value, "$") {
48+ if !strings.Contains(value, "$") {
42 return value, nil
49 return value, nil
43 }
50 }
44
51
45− if strings.HasPrefix(value, "$(") && strings.HasSuffix(value, ")") {
52+ result := value
46− command := strings.TrimSuffix(strings.TrimPrefix(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]
47 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
81 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
48− defer cancel()
49
82
50 stdout, _, err := r.shell.Exec(ctx, command)
83 stdout, _, err := r.shell.Exec(ctx, command)
84+ cancel()
51 if err != nil {
85 if err != nil {
52− return "", fmt.Errorf("command execution failed: %w", err)
86+ return "", fmt.Errorf("command execution failed for '%s': %w", command, err)
53 }
87 }
88+
89+ // Replace the $(command) with the output
54− return strings.TrimSpace(stdout), nil
90+ replacement := strings.TrimSpace(stdout)
91+ result = result[:start] + replacement + result[end+1:]
55 }
92 }
56
93
57− if after, ok := strings.CutPrefix(value, "$"); ok {
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+ }
58− varName := after
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+
59− value = r.env.Get(varName)
142+ envValue := r.env.Get(varName)
60− if value == "" {
143+ if envValue == "" {
61 return "", fmt.Errorf("environment variable %q not set", varName)
144 return "", fmt.Errorf("environment variable %q not set", varName)
62 }
145 }
63− return value, nil
146+
147+ result = result[:start] + envValue + result[end:]
148+ searchStart = start + len(envValue) // Continue searching after the replacement
64 }
149 }
65− return "", fmt.Errorf("invalid value format: %s", value)
150+
151+ return result, nil
66}
152}
67
153
68type environmentVariableResolver struct {
154type environmentVariableResolver struct {
internal/config/resolve_test.go+166−11
@@ -47,17 +47,7 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
47 envVars: map[string]string{},
47 envVars: map[string]string{},
48 expectError: true,
48 expectError: true,
49 },
49 },
50− {
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− },
61 {
51 {
62 name: "shell command with whitespace trimming",
52 name: "shell command with whitespace trimming",
63 value: "$(echo ' spaced ')",
53 value: "$(echo ' spaced ')",
@@ -104,6 +94,171 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
104 }
94 }
105}
95}
106
96
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+
107func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
262func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
108 tests := []struct {
263 tests := []struct {
109 name string
264 name string
internal/llm/provider/anthropic.go+45−10
@@ -39,8 +39,30 @@ func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicCl
39
39
40func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
40func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
41 anthropicClientOptions := []option.RequestOption{}
41 anthropicClientOptions := []option.RequestOption{}
42− if 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
43− anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
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")
44 }
66 }
45 if useBedrock {
67 if useBedrock {
46 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
68 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
@@ -200,6 +222,25 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
200 maxTokens = int64(a.adjustedMaxTokens)
222 maxTokens = int64(a.adjustedMaxTokens)
201 }
223 }
202
224
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+
203 return anthropic.MessageNewParams{
244 return anthropic.MessageNewParams{
204 Model: anthropic.Model(model.ID),
245 Model: anthropic.Model(model.ID),
205 MaxTokens: maxTokens,
246 MaxTokens: maxTokens,
@@ -207,14 +248,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
207 Messages: messages,
248 Messages: messages,
208 Tools: tools,
249 Tools: tools,
209 Thinking: thinkingParam,
250 Thinking: thinkingParam,
210− System: []anthropic.TextBlockParam{
251+ System: systemBlocks,
211− {
212− Text: a.providerOptions.systemMessage,
213− CacheControl: anthropic.CacheControlEphemeralParam{
214− Type: "ephemeral",
215− },
216− },
217− },
218 }
252 }
219}
253}
220
254
@@ -393,6 +427,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
393 close(eventChan)
427 close(eventChan)
394 return
428 return
395 }
429 }
430+
396 // If there is an error we are going to see if we can retry the call
431 // If there is an error we are going to see if we can retry the call
397 retry, after, retryErr := a.shouldRetry(attempts, err)
432 retry, after, retryErr := a.shouldRetry(attempts, err)
398 if retryErr != nil {
433 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
180 if modelConfig.MaxTokens > 0 {
180 if modelConfig.MaxTokens > 0 {
181 maxTokens = modelConfig.MaxTokens
181 maxTokens = modelConfig.MaxTokens
182 }
182 }
183+ systemMessage := g.providerOptions.systemMessage
184+ if g.providerOptions.systemPromptPrefix != "" {
185+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
186+ }
183 history := geminiMessages[:len(geminiMessages)-1] // All but last message
187 history := geminiMessages[:len(geminiMessages)-1] // All but last message
184 lastMsg := geminiMessages[len(geminiMessages)-1]
188 lastMsg := geminiMessages[len(geminiMessages)-1]
185 config := &genai.GenerateContentConfig{
189 config := &genai.GenerateContentConfig{
186 MaxOutputTokens: int32(maxTokens),
190 MaxOutputTokens: int32(maxTokens),
187 SystemInstruction: &genai.Content{
191 SystemInstruction: &genai.Content{
188− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
192+ Parts: []*genai.Part{{Text: systemMessage}},
189 },
193 },
190 }
194 }
191 config.Tools = g.convertTools(tools)
195 config.Tools = g.convertTools(tools)
@@ -280,12 +284,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
280 if g.providerOptions.maxTokens > 0 {
284 if g.providerOptions.maxTokens > 0 {
281 maxTokens = g.providerOptions.maxTokens
285 maxTokens = g.providerOptions.maxTokens
282 }
286 }
287+ systemMessage := g.providerOptions.systemMessage
288+ if g.providerOptions.systemPromptPrefix != "" {
289+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
290+ }
283 history := geminiMessages[:len(geminiMessages)-1] // All but last message
291 history := geminiMessages[:len(geminiMessages)-1] // All but last message
284 lastMsg := geminiMessages[len(geminiMessages)-1]
292 lastMsg := geminiMessages[len(geminiMessages)-1]
285 config := &genai.GenerateContentConfig{
293 config := &genai.GenerateContentConfig{
286 MaxOutputTokens: int32(maxTokens),
294 MaxOutputTokens: int32(maxTokens),
287 SystemInstruction: &genai.Content{
295 SystemInstruction: &genai.Content{
288− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
296+ Parts: []*genai.Part{{Text: systemMessage}},
289 },
297 },
290 }
298 }
291 config.Tools = g.convertTools(tools)
299 config.Tools = g.convertTools(tools)
internal/llm/provider/openai.go+5−1
@@ -57,7 +57,11 @@ func createOpenAIClient(opts providerClientOptions) openai.Client {
57
57
58func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
58func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
59 // Add system message first
59 // Add system message first
60+ systemMessage := o.providerOptions.systemMessage
61+ if o.providerOptions.systemPromptPrefix != "" {
62+ systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
63+ }
60− openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
64+ openaiMessages = append(openaiMessages, openai.SystemMessage(systemMessage))
61
65
62 for _, msg := range messages {
66 for _, msg := range messages {
63 switch msg.Role {
67 switch msg.Role {
internal/llm/provider/provider.go+28−16
@@ -61,17 +61,18 @@ type Provider interface {
61}
61}
62
62
63type providerClientOptions struct {
63type providerClientOptions struct {
64− baseURL string
64+ baseURL string
65− config config.ProviderConfig
65+ config config.ProviderConfig
66− apiKey string
66+ apiKey string
67− modelType config.SelectedModelType
67+ modelType config.SelectedModelType
68− model func(config.SelectedModelType) catwalk.Model
68+ model func(config.SelectedModelType) catwalk.Model
69− disableCache bool
69+ disableCache bool
70− systemMessage string
70+ systemMessage string
71+ systemPromptPrefix string
71− maxTokens int64
72+ maxTokens int64
72− extraHeaders map[string]string
73+ extraHeaders map[string]string
73− extraBody map[string]any
74+ extraBody map[string]any
74− extraParams map[string]string
75+ extraParams map[string]string
75}
76}
76
77
77type ProviderClientOption func(*providerClientOptions)
78type ProviderClientOption func(*providerClientOptions)
@@ -143,12 +144,23 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
143 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
144 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
144 }
145 }
145
146
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+
146 clientOptions := providerClientOptions{
157 clientOptions := providerClientOptions{
147− baseURL: cfg.BaseURL,
158+ baseURL: cfg.BaseURL,
148− config: cfg,
159+ config: cfg,
149− apiKey: resolvedAPIKey,
160+ apiKey: resolvedAPIKey,
150− extraHeaders: cfg.ExtraHeaders,
161+ extraHeaders: resolvedExtraHeaders,
151− extraBody: cfg.ExtraBody,
162+ extraBody: cfg.ExtraBody,
163+ systemPromptPrefix: cfg.SystemPromptPrefix,
152 model: func(tp config.SelectedModelType) catwalk.Model {
164 model: func(tp config.SelectedModelType) catwalk.Model {
153 return *config.Get().GetModelByType(tp)
165 return *config.Get().GetModelByType(tp)
154 },
166 },
internal/tui/components/chat/editor/editor.go+42−15
@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
161 )
161 )
162}
162}
163
163
164+func (m *editorCmp) repositionCompletions() tea.Msg {
165+ x, y := m.completionsPosition()
166+ return completions.RepositionCompletionsMsg{X: x, Y: y}
167+}
168+
164func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165 var cmd tea.Cmd
170 var cmd tea.Cmd
166 var cmds []tea.Cmd
171 var cmds []tea.Cmd
167 switch msg := msg.(type) {
172 switch msg := msg.(type) {
173+ case tea.WindowSizeMsg:
174+ return m, m.repositionCompletions
168 case filepicker.FilePickedMsg:
175 case filepicker.FilePickedMsg:
169 if len(m.attachments) >= maxAttachments {
176 if len(m.attachments) >= maxAttachments {
170 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
177 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) {
182 return m, nil
189 return m, nil
183 }
190 }
184 if item, ok := msg.Value.(FileCompletionItem); ok {
191 if item, ok := msg.Value.(FileCompletionItem); ok {
192+ word := m.textarea.Word()
185 // If the selected item is a file, insert its path into the textarea
193 // If the selected item is a file, insert its path into the textarea
186 value := m.textarea.Value()
194 value := m.textarea.Value()
187− value = value[:m.completionsStartIndex]
195+ value = value[:m.completionsStartIndex] + // Remove the current query
188− value += item.Path
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.
189 m.textarea.SetValue(value)
199 m.textarea.SetValue(value)
200+ m.textarea.MoveToEnd()
190 if !msg.Insert {
201 if !msg.Insert {
191 m.isCompletionsOpen = false
202 m.isCompletionsOpen = false
192 m.currentQuery = ""
203 m.currentQuery = ""
193 m.completionsStartIndex = 0
204 m.completionsStartIndex = 0
194 }
205 }
195− return m, nil
196 }
206 }
197 case openEditorMsg:
207 case openEditorMsg:
198 m.textarea.SetValue(msg.Text)
208 m.textarea.SetValue(msg.Text)
199 m.textarea.MoveToEnd()
209 m.textarea.MoveToEnd()
200 case tea.KeyPressMsg:
210 case tea.KeyPressMsg:
211+ cur := m.textarea.Cursor()
212+ curIdx := m.textarea.Width()*cur.Y + cur.X
201 switch {
213 switch {
202 // Completions
214 // Completions
203 case msg.String() == "/" && !m.isCompletionsOpen &&
215 case msg.String() == "/" && !m.isCompletionsOpen &&
204− // only show if beginning of prompt, or if previous char is a space:
216+ // only show if beginning of prompt, or if previous char is a space or newline:
205− (len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
217+ (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
206 m.isCompletionsOpen = true
218 m.isCompletionsOpen = true
207 m.currentQuery = ""
219 m.currentQuery = ""
208− m.completionsStartIndex = len(m.textarea.Value())
220+ m.completionsStartIndex = curIdx
209 cmds = append(cmds, m.startCompletions)
221 cmds = append(cmds, m.startCompletions)
210− case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
222+ case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
211 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
223 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
212 }
224 }
213 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
225 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244 }
256 }
245 if key.Matches(msg, m.keyMap.Newline) {
257 if key.Matches(msg, m.keyMap.Newline) {
246 m.textarea.InsertRune('\n')
258 m.textarea.InsertRune('\n')
259+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
247 }
260 }
248 // Handle Enter key
261 // Handle Enter key
249 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
262 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) {
275 // XXX: wont' work if editing in the middle of the field.
288 // XXX: wont' work if editing in the middle of the field.
276 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
289 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
277 m.currentQuery = word[1:]
290 m.currentQuery = word[1:]
291+ x, y := m.completionsPosition()
292+ x -= len(m.currentQuery)
278 m.isCompletionsOpen = true
293 m.isCompletionsOpen = true
294+ cmds = append(cmds,
279− cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
295+ util.CmdHandler(completions.FilterCompletionsMsg{
280− Query: m.currentQuery,
296+ Query: m.currentQuery,
281− Reopen: m.isCompletionsOpen,
297+ Reopen: m.isCompletionsOpen,
298+ X: x,
299+ Y: y,
282− }))
300+ }),
283− } else {
301+ )
302+ } else if m.isCompletionsOpen {
284 m.isCompletionsOpen = false
303 m.isCompletionsOpen = false
285 m.currentQuery = ""
304 m.currentQuery = ""
286 m.completionsStartIndex = 0
305 m.completionsStartIndex = 0
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293 return m, tea.Batch(cmds...)
312 return m, tea.Batch(cmds...)
294}
313}
295
314
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+
296func (m *editorCmp) Cursor() *tea.Cursor {
325func (m *editorCmp) Cursor() *tea.Cursor {
297 cursor := m.textarea.Cursor()
326 cursor := m.textarea.Cursor()
298 if cursor != nil {
327 if cursor != nil {
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
373 })
402 })
374 }
403 }
375
404
376− cur := m.textarea.Cursor()
405+ x, y := m.completionsPosition()
377− x := cur.X + m.x // adjust for padding
378− y := cur.Y + m.y + 1
379 return completions.OpenCompletionsMsg{
406 return completions.OpenCompletionsMsg{
380 Completions: completionItems,
407 Completions: completionItems,
381 X: x,
408 X: x,
internal/tui/components/completions/completions.go+77−23
@@ -27,6 +27,12 @@ type OpenCompletionsMsg struct {
27type FilterCompletionsMsg struct {
27type FilterCompletionsMsg struct {
28 Query string // The query to filter completions
28 Query string // The query to filter completions
29 Reopen bool
29 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
30}
36}
31
37
32type CompletionsClosedMsg struct{}
38type CompletionsClosedMsg struct{}
@@ -53,18 +59,24 @@ type Completions interface {
53type listModel = list.FilterableList[list.CompletionItem[any]]
59type listModel = list.FilterableList[list.CompletionItem[any]]
54
60
55type completionsCmp struct {
61type completionsCmp struct {
56− width int
62+ wWidth int // The window width
63+ wHeight int // The window height
64+ width int
65+ lastWidth int
57− height int // Height of the completions component`
66+ height int // Height of the completions component`
58− x int // X position for the completions popup
67+ x, xorig int // X position for the completions popup
59− y int // Y position for the completions popup
68+ y int // Y position for the completions popup
60− open bool // Indicates if the completions are open
69+ open bool // Indicates if the completions are open
61− keyMap KeyMap
70+ keyMap KeyMap
62
71
63 list listModel
72 list listModel
64 query string // The current filter query
73 query string // The current filter query
65}
74}
66
75
76+const (
67−const maxCompletionsWidth = 80 // Maximum width for the completions popup
77+ maxCompletionsWidth = 80 // Maximum width for the completions popup
78+ minCompletionsWidth = 20 // Minimum width for the completions popup
79+)
68
80
69func New() Completions {
81func New() Completions {
70 completionsKeyMap := DefaultKeyMap()
82 completionsKeyMap := DefaultKeyMap()
@@ -88,7 +100,7 @@ func New() Completions {
88 )
100 )
89 return &completionsCmp{
101 return &completionsCmp{
90 width: 0,
102 width: 0,
91− height: 0,
103+ height: maxCompletionsHeight,
92 list: l,
104 list: l,
93 query: "",
105 query: "",
94 keyMap: completionsKeyMap,
106 keyMap: completionsKeyMap,
@@ -107,8 +119,7 @@ func (c *completionsCmp) Init() tea.Cmd {
107func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108 switch msg := msg.(type) {
120 switch msg := msg.(type) {
109 case tea.WindowSizeMsg:
121 case tea.WindowSizeMsg:
110− c.width = min(msg.Width-c.x, maxCompletionsWidth)
122+ c.wWidth, c.wHeight = msg.Width, msg.Height
111− c.height = min(msg.Height-c.y, 15)
112 return c, nil
123 return c, nil
113 case tea.KeyPressMsg:
124 case tea.KeyPressMsg:
114 switch {
125 switch {
@@ -129,7 +140,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129 selectedItem := *s
140 selectedItem := *s
130 c.list.SetSelected(selectedItem.ID())
141 c.list.SetSelected(selectedItem.ID())
131 return c, util.CmdHandler(SelectCompletionMsg{
142 return c, util.CmdHandler(SelectCompletionMsg{
132− Value: selectedItem,
143+ Value: selectedItem.Value(),
133 Insert: true,
144 Insert: true,
134 })
145 })
135 case key.Matches(msg, c.keyMap.DownInsert):
146 case key.Matches(msg, c.keyMap.DownInsert):
@@ -140,7 +151,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140 selectedItem := *s
151 selectedItem := *s
141 c.list.SetSelected(selectedItem.ID())
152 c.list.SetSelected(selectedItem.ID())
142 return c, util.CmdHandler(SelectCompletionMsg{
153 return c, util.CmdHandler(SelectCompletionMsg{
143− Value: selectedItem,
154+ Value: selectedItem.Value(),
144 Insert: true,
155 Insert: true,
145 })
156 })
146 case key.Matches(msg, c.keyMap.Select):
157 case key.Matches(msg, c.keyMap.Select):
@@ -151,18 +162,21 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151 selectedItem := *s
162 selectedItem := *s
152 c.open = false // Close completions after selection
163 c.open = false // Close completions after selection
153 return c, util.CmdHandler(SelectCompletionMsg{
164 return c, util.CmdHandler(SelectCompletionMsg{
154− Value: selectedItem,
165+ Value: selectedItem.Value(),
155 })
166 })
156 case key.Matches(msg, c.keyMap.Cancel):
167 case key.Matches(msg, c.keyMap.Cancel):
157 return c, util.CmdHandler(CloseCompletionsMsg{})
168 return c, util.CmdHandler(CloseCompletionsMsg{})
158 }
169 }
170+ case RepositionCompletionsMsg:
171+ c.x, c.y = msg.X, msg.Y
172+ c.adjustPosition()
159 case CloseCompletionsMsg:
173 case CloseCompletionsMsg:
160 c.open = false
174 c.open = false
161 return c, util.CmdHandler(CompletionsClosedMsg{})
175 return c, util.CmdHandler(CompletionsClosedMsg{})
162 case OpenCompletionsMsg:
176 case OpenCompletionsMsg:
163 c.open = true
177 c.open = true
164 c.query = ""
178 c.query = ""
165− c.x = msg.X
179+ c.x, c.xorig = msg.X, msg.X
166 c.y = msg.Y
180 c.y = msg.Y
167 items := []list.CompletionItem[any]{}
181 items := []list.CompletionItem[any]{}
168 t := styles.CurrentTheme()
182 t := styles.CurrentTheme()
@@ -174,10 +188,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174 )
188 )
175 items = append(items, item)
189 items = append(items, item)
176 }
190 }
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
177− c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
199+ c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
178 return c, tea.Batch(
200 return c, tea.Batch(
179− c.list.SetSize(c.width, c.height),
180 c.list.SetItems(items),
201 c.list.SetItems(items),
202+ c.list.SetSize(c.width, c.height),
181 util.CmdHandler(CompletionsOpenedMsg{}),
203 util.CmdHandler(CompletionsOpenedMsg{}),
182 )
204 )
183 case FilterCompletionsMsg:
205 case FilterCompletionsMsg:
@@ -201,8 +223,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
201 c.query = msg.Query
223 c.query = msg.Query
202 var cmds []tea.Cmd
224 var cmds []tea.Cmd
203 cmds = append(cmds, c.list.Filter(msg.Query))
225 cmds = append(cmds, c.list.Filter(msg.Query))
204− itemsLen := len(c.list.Items())
226+ items := c.list.Items()
205− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
227+ itemsLen := len(items)
228+ c.xorig = msg.X
229+ c.x, c.y = msg.X, msg.Y
230+ c.adjustPosition()
206 cmds = append(cmds, c.list.SetSize(c.width, c.height))
231 cmds = append(cmds, c.list.SetSize(c.width, c.height))
207 if itemsLen == 0 {
232 if itemsLen == 0 {
208 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
233 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -215,21 +240,50 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
215 return c, nil
240 return c, nil
216}
241}
217
242
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+
218// View implements Completions.
257// View implements Completions.
219func (c *completionsCmp) View() string {
258func (c *completionsCmp) View() string {
220 if !c.open || len(c.list.Items()) == 0 {
259 if !c.open || len(c.list.Items()) == 0 {
221 return ""
260 return ""
222 }
261 }
223
262
224− return c.style().Render(c.list.View())
225−}
226−
227−func (c *completionsCmp) style() lipgloss.Style {
228 t := styles.CurrentTheme()
263 t := styles.CurrentTheme()
229− return t.S().Base.
264+ style := t.S().Base.
230 Width(c.width).
265 Width(c.width).
231 Height(c.height).
266 Height(c.height).
232 Background(t.BgSubtle)
267 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
233}
287}
234
288
235func (c *completionsCmp) Open() bool {
289func (c *completionsCmp) Open() bool {
internal/tui/exp/list/items.go+5
@@ -23,6 +23,7 @@ type CompletionItem[T any] interface {
23 layout.Sizeable
23 layout.Sizeable
24 HasMatchIndexes
24 HasMatchIndexes
25 Value() T
25 Value() T
26+ Text() string
26}
27}
27
28
28type completionItemCmp[T any] struct {
29type completionItemCmp[T any] struct {
@@ -312,6 +313,10 @@ func (c *completionItemCmp[T]) ID() string {
312 return c.id
313 return c.id
313}
314}
314
315
316+func (c *completionItemCmp[T]) Text() string {
317+ return c.text
318+}
319+
315type ItemSection interface {
320type ItemSection interface {
316 Item
321 Item
317 layout.Sizeable
322 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) {
173 }
173 }
174 return p, nil
174 return p, nil
175 case tea.WindowSizeMsg:
175 case tea.WindowSizeMsg:
176+ u, cmd := p.editor.Update(msg)
177+ p.editor = u.(editor.Editor)
176− return p, p.SetSize(msg.Width, msg.Height)
178+ return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
177 case CancelTimerExpiredMsg:
179 case CancelTimerExpiredMsg:
178 p.isCanceling = false
180 p.isCanceling = false
179 return p, nil
181 return p, nil
internal/tui/tui.go+2−11
@@ -111,19 +111,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111 return a, a.handleWindowResize(msg.Width, msg.Height)
111 return a, a.handleWindowResize(msg.Width, msg.Height)
112
112
113 // Completions messages
113 // Completions messages
114− case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
114+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
115+ completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
115 u, completionCmd := a.completions.Update(msg)
116 u, completionCmd := a.completions.Update(msg)
116 a.completions = u.(completions.Completions)
117 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− }
127 return a, completionCmd
118 return a, completionCmd
128
119
129 // Dialog messages
120 // Dialog messages