Jay V 9 месяцев назад
Родитель
Сommit
9049295cc9
43 измененных файлов с 2578 добавлено и 534 удалено
  1. 2 1
      .gitignore
  2. 129 7
      README.md
  3. 292 0
      cmd/non_interactive_mode.go
  4. 32 0
      cmd/root.go
  5. 1 0
      cmd/schema/main.go
  6. 9 4
      go.mod
  7. 8 6
      go.sum
  8. 13 0
      internal/app/app.go
  9. 52 0
      internal/config/config.go
  10. 46 0
      internal/format/format.go
  11. 90 0
      internal/format/format_test.go
  12. 1 11
      internal/llm/agent/agent.go
  13. 25 0
      internal/llm/models/bedrock.go
  14. 2 0
      internal/llm/models/groq.go
  15. 4 48
      internal/llm/models/models.go
  16. 15 0
      internal/llm/models/openai.go
  17. 38 0
      internal/llm/models/vertexai.go
  18. 12 6
      internal/llm/provider/gemini.go
  19. 10 297
      internal/llm/provider/openai.go
  20. 317 0
      internal/llm/provider/openai_completion.go
  21. 393 0
      internal/llm/provider/openai_response.go
  22. 6 1
      internal/llm/provider/provider.go
  23. 34 0
      internal/llm/provider/vertexai.go
  24. 3 18
      internal/llm/tools/edit.go
  25. 20 4
      internal/llm/tools/shell/shell.go
  26. 1 7
      internal/llm/tools/write.go
  27. 102 10
      internal/tui/components/chat/editor.go
  28. 2 0
      internal/tui/components/chat/messages.go
  29. 2 8
      internal/tui/components/chat/sidebar.go
  30. 0 2
      internal/tui/components/dialog/models.go
  31. 12 1
      internal/tui/components/dialog/permission.go
  32. 178 0
      internal/tui/components/dialog/tools.go
  33. 3 3
      internal/tui/components/logs/details.go
  34. 127 0
      internal/tui/components/spinner/spinner.go
  35. 24 0
      internal/tui/components/spinner/spinner_test.go
  36. 49 0
      internal/tui/image/clipboard_unix.go
  37. 192 0
      internal/tui/image/clipboard_windows.go
  38. 12 0
      internal/tui/image/images.go
  39. 4 7
      internal/tui/layout/container.go
  40. 48 0
      internal/tui/theme/manager.go
  41. 27 2
      internal/tui/theme/theme.go
  42. 127 0
      internal/tui/tui.go
  43. 114 91
      opencode-schema.json

+ 2 - 1
.gitignore

@@ -42,4 +42,5 @@ Thumbs.db
 .env.local
 
 .opencode/
-
+# ignore locally built binary
+opencode*

+ 129 - 7
README.md

@@ -74,6 +74,8 @@ You can configure OpenCode using environment variables:
 | `ANTHROPIC_API_KEY`        | For Claude models                                      |
 | `OPENAI_API_KEY`           | For OpenAI models                                      |
 | `GEMINI_API_KEY`           | For Google Gemini models                               |
+| `VERTEXAI_PROJECT`         | For Google Cloud VertexAI (Gemini)                     |
+| `VERTEXAI_LOCATION`        | For Google Cloud VertexAI (Gemini)                     |
 | `GROQ_API_KEY`             | For Groq models                                        |
 | `AWS_ACCESS_KEY_ID`        | For AWS Bedrock (Claude)                               |
 | `AWS_SECRET_ACCESS_KEY`    | For AWS Bedrock (Claude)                               |
@@ -81,7 +83,6 @@ You can configure OpenCode using environment variables:
 | `AZURE_OPENAI_ENDPOINT`    | For Azure OpenAI models                                |
 | `AZURE_OPENAI_API_KEY`     | For Azure OpenAI models (optional when using Entra ID) |
 | `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models                                |
-
 ### Configuration File Structure
 
 ```json
@@ -135,6 +136,10 @@ You can configure OpenCode using environment variables:
       "command": "gopls"
     }
   },
+  "shell": {
+    "path": "/bin/zsh",
+    "args": ["-l"]
+  },
   "debug": false,
   "debugLSP": false
 }
@@ -189,7 +194,43 @@ OpenCode supports a variety of AI models from different providers:
 - O3 family (o3, o3-mini)
 - O4 Mini
 
-## Usage
+### Google Cloud VertexAI
+
+- Gemini 2.5
+- Gemini 2.5 Flash
+
+## Using Bedrock Models
+
+To use bedrock models with OpenCode you need three things.
+
+1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
+2. Access to the corresponding model in AWS Bedrock in your region.
+    a. You can request access in the AWS console on the Bedrock -> "Model access" page.
+3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
+
+```json
+{
+  "agents": {
+    "primary": {
+      "model": "bedrock.claude-3.7-sonnet",
+      "maxTokens": 5000,
+      "reasoningEffort": ""
+    },
+    "task": {
+      "model": "bedrock.claude-3.7-sonnet",
+      "maxTokens": 5000,
+      "reasoningEffort": ""
+    },
+    "title": {
+      "model": "bedrock.claude-3.7-sonnet",
+      "maxTokens": 80,
+      "reasoningEffort": ""
+    }
+  },
+}
+```
+ 
+## Interactive Mode Usage
 
 ```bash
 # Start OpenCode
@@ -202,13 +243,65 @@ opencode -d
 opencode -c /path/to/project
 ```
 
+## Non-interactive Prompt Mode
+
+You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
+
+```bash
+# Run a single prompt and print the AI's response to the terminal
+opencode -p "Explain the use of context in Go"
+
+# Get response in JSON format
+opencode -p "Explain the use of context in Go" -f json
+
+# Run without showing the spinner
+opencode -p "Explain the use of context in Go" -q
+
+# Enable verbose logging to stderr
+opencode -p "Explain the use of context in Go" --verbose
+
+# Restrict the agent to only use specific tools
+opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
+
+# Prevent the agent from using specific tools
+opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
+```
+
+In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
+
+### Tool Restrictions
+
+You can control which tools the AI assistant has access to in non-interactive mode:
+
+- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
+- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
+
+These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
+
+### Output Formats
+
+OpenCode supports the following output formats in non-interactive mode:
+
+| Format | Description                            |
+| ------ | -------------------------------------- |
+| `text` | Plain text output (default)            |
+| `json` | Output wrapped in a JSON object        |
+
+The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
+
 ## Command-line Flags
 
-| Flag      | Short | Description                   |
-| --------- | ----- | ----------------------------- |
-| `--help`  | `-h`  | Display help information      |
-| `--debug` | `-d`  | Enable debug mode             |
-| `--cwd`   | `-c`  | Set current working directory |
+| Flag              | Short | Description                                                |
+| ----------------- | ----- | ---------------------------------------------------------- |
+| `--help`          | `-h`  | Display help information                                   |
+| `--debug`         | `-d`  | Enable debug mode                                          |
+| `--cwd`           | `-c`  | Set current working directory                              |
+| `--prompt`        | `-p`  | Run a single prompt in non-interactive mode                |
+| `--output-format` | `-f`  | Output format for non-interactive mode (text, json)        |
+| `--quiet`         | `-q`  | Hide spinner in non-interactive mode                       |
+| `--verbose`       |       | Display logs to stderr in non-interactive mode             |
+| `--allowedTools`  |       | Restrict the agent to only use specified tools             |
+| `--excludedTools` |       | Prevent the agent from using specified tools               |
 
 ## Keyboard Shortcuts
 
@@ -374,6 +467,35 @@ You can define any of the following color keys in your `customTheme`:
 
 You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
 
+### Shell Configuration
+
+OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
+1. The shell specified in the config file (if provided)
+2. The shell from the `$SHELL` environment variable (if available)
+3. Falls back to `/bin/bash` if neither of the above is available
+
+To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
+
+```json
+{
+  "shell": {
+    "path": "/bin/zsh",
+    "args": ["-l"]
+  }
+}
+```
+
+You can specify any shell executable and custom arguments:
+
+```json
+{
+  "shell": {
+    "path": "/usr/bin/fish",
+    "args": []
+  }
+}
+```
+
 ## Architecture
 
 OpenCode is built with a modular architecture:

+ 292 - 0
cmd/non_interactive_mode.go

@@ -0,0 +1,292 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"sync"
+	"time"
+
+	"log/slog"
+
+	charmlog "github.com/charmbracelet/log"
+	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/config"
+	"github.com/sst/opencode/internal/db"
+	"github.com/sst/opencode/internal/format"
+	"github.com/sst/opencode/internal/llm/agent"
+	"github.com/sst/opencode/internal/llm/tools"
+	"github.com/sst/opencode/internal/message"
+	"github.com/sst/opencode/internal/permission"
+	"github.com/sst/opencode/internal/tui/components/spinner"
+	"github.com/sst/opencode/internal/tui/theme"
+)
+
+// syncWriter is a thread-safe writer that prevents interleaved output
+type syncWriter struct {
+	w  io.Writer
+	mu sync.Mutex
+}
+
+// Write implements io.Writer
+func (sw *syncWriter) Write(p []byte) (n int, err error) {
+	sw.mu.Lock()
+	defer sw.mu.Unlock()
+	return sw.w.Write(p)
+}
+
+// newSyncWriter creates a new synchronized writer
+func newSyncWriter(w io.Writer) io.Writer {
+	return &syncWriter{w: w}
+}
+
+// filterTools filters the provided tools based on allowed or excluded tool names
+func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string) []tools.BaseTool {
+	// If neither allowed nor excluded tools are specified, return all tools
+	if len(allowedTools) == 0 && len(excludedTools) == 0 {
+		return allTools
+	}
+
+	// Create a map for faster lookups
+	allowedMap := make(map[string]bool)
+	for _, name := range allowedTools {
+		allowedMap[name] = true
+	}
+
+	excludedMap := make(map[string]bool)
+	for _, name := range excludedTools {
+		excludedMap[name] = true
+	}
+
+	var filteredTools []tools.BaseTool
+
+	for _, tool := range allTools {
+		toolName := tool.Info().Name
+
+		// If we have an allowed list, only include tools in that list
+		if len(allowedTools) > 0 {
+			if allowedMap[toolName] {
+				filteredTools = append(filteredTools, tool)
+			}
+		} else if len(excludedTools) > 0 {
+			// If we have an excluded list, include all tools except those in the list
+			if !excludedMap[toolName] {
+				filteredTools = append(filteredTools, tool)
+			}
+		}
+	}
+
+	return filteredTools
+}
+
+// handleNonInteractiveMode processes a single prompt in non-interactive mode
+func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string) error {
+	// Initial log message using standard slog
+	slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet, "verbose", verbose,
+		"allowedTools", allowedTools, "excludedTools", excludedTools)
+
+	// Sanity check for mutually exclusive flags
+	if quiet && verbose {
+		return fmt.Errorf("--quiet and --verbose flags cannot be used together")
+	}
+
+	// Set up logging to stderr if verbose mode is enabled
+	if verbose {
+		// Create a synchronized writer to prevent interleaved output
+		syncWriter := newSyncWriter(os.Stderr)
+
+		// Create a charmbracelet/log logger that writes to the synchronized writer
+		charmLogger := charmlog.NewWithOptions(syncWriter, charmlog.Options{
+			Level:           charmlog.DebugLevel,
+			ReportCaller:    true,
+			ReportTimestamp: true,
+			TimeFormat:      time.RFC3339,
+			Prefix:          "OpenCode",
+		})
+
+		// Set the global logger for charmbracelet/log
+		charmlog.SetDefault(charmLogger)
+
+		// Create a slog handler that uses charmbracelet/log
+		// This will forward all slog logs to charmbracelet/log
+		slog.SetDefault(slog.New(charmLogger))
+
+		// Log a message to confirm verbose logging is enabled
+		charmLogger.Info("Verbose logging enabled")
+	}
+
+	// Start spinner if not in quiet mode
+	var s *spinner.Spinner
+	if !quiet {
+		// Get the current theme to style the spinner
+		currentTheme := theme.CurrentTheme()
+
+		// Create a themed spinner
+		if currentTheme != nil {
+			// Use the primary color from the theme
+			s = spinner.NewThemedSpinner("Thinking...", currentTheme.Primary())
+		} else {
+			// Fallback to default spinner if no theme is available
+			s = spinner.NewSpinner("Thinking...")
+		}
+
+		s.Start()
+		defer s.Stop()
+	}
+
+	// Connect DB, this will also run migrations
+	conn, err := db.Connect()
+	if err != nil {
+		return err
+	}
+
+	// Create a context with cancellation
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	// Create the app
+	app, err := app.New(ctx, conn)
+	if err != nil {
+		slog.Error("Failed to create app", "error", err)
+		return err
+	}
+
+	// Create a new session for this prompt
+	session, err := app.Sessions.Create(ctx, "Non-interactive prompt")
+	if err != nil {
+		return fmt.Errorf("failed to create session: %w", err)
+	}
+
+	// Set the session as current
+	app.CurrentSession = &session
+
+	// Auto-approve all permissions for this session
+	permission.AutoApproveSession(ctx, session.ID)
+
+	// Create the user message
+	_, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{
+		Role:  message.User,
+		Parts: []message.ContentPart{message.TextContent{Text: prompt}},
+	})
+	if err != nil {
+		return fmt.Errorf("failed to create message: %w", err)
+	}
+
+	// If tool restrictions are specified, create a new agent with filtered tools
+	if len(allowedTools) > 0 || len(excludedTools) > 0 {
+		// Initialize MCP tools synchronously to ensure they're included in filtering
+		mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second)
+		agent.GetMcpTools(mcpCtx, app.Permissions)
+		mcpCancel()
+
+		// Get all available tools including MCP tools
+		allTools := agent.PrimaryAgentTools(
+			app.Permissions,
+			app.Sessions,
+			app.Messages,
+			app.History,
+			app.LSPClients,
+		)
+
+		// Filter tools based on allowed/excluded lists
+		filteredTools := filterTools(allTools, allowedTools, excludedTools)
+
+		// Log the filtered tools for debugging
+		var toolNames []string
+		for _, tool := range filteredTools {
+			toolNames = append(toolNames, tool.Info().Name)
+		}
+		slog.Debug("Using filtered tools", "count", len(filteredTools), "tools", toolNames)
+
+		// Create a new agent with the filtered tools
+		restrictedAgent, err := agent.NewAgent(
+			config.AgentPrimary,
+			app.Sessions,
+			app.Messages,
+			filteredTools,
+		)
+		if err != nil {
+			return fmt.Errorf("failed to create restricted agent: %w", err)
+		}
+
+		// Use the restricted agent for this request
+		eventCh, err := restrictedAgent.Run(ctx, session.ID, prompt)
+		if err != nil {
+			return fmt.Errorf("failed to run restricted agent: %w", err)
+		}
+
+		// Wait for the response
+		var response message.Message
+		for event := range eventCh {
+			if event.Err() != nil {
+				return fmt.Errorf("agent error: %w", event.Err())
+			}
+			response = event.Response()
+		}
+
+		// Format and print the output
+		content := ""
+		if textContent := response.Content(); textContent != nil {
+			content = textContent.Text
+		}
+
+		formattedOutput, err := format.FormatOutput(content, outputFormat)
+		if err != nil {
+			return fmt.Errorf("failed to format output: %w", err)
+		}
+
+		// Stop spinner before printing output
+		if !quiet && s != nil {
+			s.Stop()
+		}
+
+		// Print the formatted output to stdout
+		fmt.Println(formattedOutput)
+
+		// Shutdown the app
+		app.Shutdown()
+
+		return nil
+	}
+
+	// Run the default agent if no tool restrictions
+	eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt)
+	if err != nil {
+		return fmt.Errorf("failed to run agent: %w", err)
+	}
+
+	// Wait for the response
+	var response message.Message
+	for event := range eventCh {
+		if event.Err() != nil {
+			return fmt.Errorf("agent error: %w", event.Err())
+		}
+		response = event.Response()
+	}
+
+	// Get the text content from the response
+	content := ""
+	if textContent := response.Content(); textContent != nil {
+		content = textContent.Text
+	}
+
+	// Format the output according to the specified format
+	formattedOutput, err := format.FormatOutput(content, outputFormat)
+	if err != nil {
+		return fmt.Errorf("failed to format output: %w", err)
+	}
+
+	// Stop spinner before printing output
+	if !quiet && s != nil {
+		s.Stop()
+	}
+
+	// Print the formatted output to stdout
+	fmt.Println(formattedOutput)
+
+	// Shutdown the app
+	app.Shutdown()
+
+	return nil
+}

+ 32 - 0
cmd/root.go

@@ -15,6 +15,7 @@ import (
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/db"
+	"github.com/sst/opencode/internal/format"
 	"github.com/sst/opencode/internal/llm/agent"
 	"github.com/sst/opencode/internal/logging"
 	"github.com/sst/opencode/internal/lsp/discovery"
@@ -88,6 +89,25 @@ to assist developers in writing, debugging, and understanding code directly from
 			return err
 		}
 
+		// Check if we're in non-interactive mode
+		prompt, _ := cmd.Flags().GetString("prompt")
+		if prompt != "" {
+			outputFormatStr, _ := cmd.Flags().GetString("output-format")
+			outputFormat := format.OutputFormat(outputFormatStr)
+			if !outputFormat.IsValid() {
+				return fmt.Errorf("invalid output format: %s", outputFormatStr)
+			}
+
+			quiet, _ := cmd.Flags().GetBool("quiet")
+			verbose, _ := cmd.Flags().GetBool("verbose")
+
+			// Get tool restriction flags
+			allowedTools, _ := cmd.Flags().GetStringSlice("allowedTools")
+			excludedTools, _ := cmd.Flags().GetStringSlice("excludedTools")
+
+			return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools)
+		}
+
 		// Run LSP auto-discovery
 		if err := discovery.IntegrateLSPServers(cwd); err != nil {
 			slog.Warn("Failed to auto-discover LSP servers", "error", err)
@@ -296,4 +316,16 @@ func init() {
 	rootCmd.Flags().BoolP("version", "v", false, "Version")
 	rootCmd.Flags().BoolP("debug", "d", false, "Debug")
 	rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
+	rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode")
+	rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
+	rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
+	rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode")
+	rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)")
+	rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)")
+
+	// Make allowedTools and excludedTools mutually exclusive
+	rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools")
+
+	// Make quiet and verbose mutually exclusive
+	rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose")
 }

+ 1 - 0
cmd/schema/main.go

@@ -227,6 +227,7 @@ func generateSchema() map[string]any {
 		string(models.ProviderOpenRouter),
 		string(models.ProviderBedrock),
 		string(models.ProviderAzure),
+		string(models.ProviderVertexAI),
 	}
 
 	providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{

+ 9 - 4
go.mod

@@ -11,7 +11,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0
 	github.com/bmatcuk/doublestar/v4 v4.8.1
 	github.com/catppuccin/go v0.3.0
-	github.com/charmbracelet/bubbles v0.20.0
+	github.com/charmbracelet/bubbles v0.21.0
 	github.com/charmbracelet/bubbletea v1.3.4
 	github.com/charmbracelet/glamour v0.9.1
 	github.com/charmbracelet/lipgloss v1.1.0
@@ -26,7 +26,7 @@ require (
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.25.0
-	github.com/openai/openai-go v0.1.0-beta.2
+	github.com/openai/openai-go v0.1.0-beta.10
 	github.com/pressly/goose/v3 v3.24.2
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/spf13/cobra v1.9.1
@@ -34,6 +34,11 @@ require (
 	github.com/stretchr/testify v1.10.0
 )
 
+require (
+	github.com/charmbracelet/log v0.4.2 // indirect
+	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
+)
+
 require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect
@@ -42,7 +47,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
-	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/atotto/clipboard v0.1.4
 	github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
@@ -115,7 +120,7 @@ require (
 	go.opentelemetry.io/otel/trace v1.35.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.37.0 // indirect
-	golang.org/x/image v0.26.0 // indirect
+	golang.org/x/image v0.26.0
 	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sync v0.13.0 // indirect
 	golang.org/x/sys v0.32.0 // indirect

+ 8 - 6
go.sum

@@ -68,8 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
-github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
-github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
 github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
@@ -78,12 +78,14 @@ github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ
 github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
+github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
 github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
 github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -177,8 +179,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
-github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
-github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
+github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU=
+github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=

+ 13 - 0
internal/app/app.go

@@ -40,6 +40,9 @@ type App struct {
 	watcherCancelFuncs []context.CancelFunc
 	cancelFuncsMutex   sync.Mutex
 	watcherWG          sync.WaitGroup
+	
+	// UI state
+	filepickerOpen bool
 }
 
 func New(ctx context.Context, conn *sql.DB) (*App, error) {
@@ -128,6 +131,16 @@ func (app *App) initTheme() {
 	}
 }
 
+// IsFilepickerOpen returns whether the filepicker is currently open
+func (app *App) IsFilepickerOpen() bool {
+	return app.filepickerOpen
+}
+
+// SetFilepickerOpen sets the state of the filepicker
+func (app *App) SetFilepickerOpen(open bool) {
+	app.filepickerOpen = open
+}
+
 // Shutdown performs a clean shutdown of the application
 func (app *App) Shutdown() {
 	// Cancel all watcher goroutines

+ 52 - 0
internal/config/config.go

@@ -73,6 +73,12 @@ type TUIConfig struct {
 	CustomTheme map[string]any `json:"customTheme,omitempty"`
 }
 
+// ShellConfig defines the configuration for the shell used by the bash tool.
+type ShellConfig struct {
+	Path string   `json:"path,omitempty"`
+	Args []string `json:"args,omitempty"`
+}
+
 // Config is the main configuration structure for the application.
 type Config struct {
 	Data         Data                              `json:"data"`
@@ -85,6 +91,7 @@ type Config struct {
 	DebugLSP     bool                              `json:"debugLSP,omitempty"`
 	ContextPaths []string                          `json:"contextPaths,omitempty"`
 	TUI          TUIConfig                         `json:"tui"`
+	Shell        ShellConfig                       `json:"shell,omitempty"`
 }
 
 // Application constants
@@ -235,6 +242,7 @@ func setProviderDefaults() {
 	// 5. OpenRouter
 	// 6. AWS Bedrock
 	// 7. Azure
+	// 8. Google Cloud VertexAI
 
 	// Anthropic configuration
 	if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
@@ -299,6 +307,15 @@ func setProviderDefaults() {
 		viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
 		return
 	}
+
+	// Google Cloud VertexAI configuration
+	if hasVertexAICredentials() {
+		viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
+		viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
+		viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
+		viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
+		return
+	}
 }
 
 // hasAWSCredentials checks if AWS credentials are available in the environment.
@@ -327,6 +344,19 @@ func hasAWSCredentials() bool {
 	return false
 }
 
+// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
+func hasVertexAICredentials() bool {
+	// Check for explicit VertexAI parameters
+	if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
+		return true
+	}
+	// Check for Google Cloud project and location
+	if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
+		return true
+	}
+	return false
+}
+
 // readConfig handles the result of reading a configuration file.
 func readConfig(err error) error {
 	if err == nil {
@@ -549,6 +579,10 @@ func getProviderAPIKey(provider models.ModelProvider) string {
 		if hasAWSCredentials() {
 			return "aws-credentials-available"
 		}
+	case models.ProviderVertexAI:
+		if hasVertexAICredentials() {
+			return "vertex-ai-credentials-available"
+		}
 	}
 	return ""
 }
@@ -669,6 +703,24 @@ func setDefaultModelForAgent(agent AgentName) bool {
 		return true
 	}
 
+	if hasVertexAICredentials() {
+		var model models.ModelID
+		maxTokens := int64(5000)
+
+		if agent == AgentTitle {
+			model = models.VertexAIGemini25Flash
+			maxTokens = 80
+		} else {
+			model = models.VertexAIGemini25
+		}
+
+		cfg.Agents[agent] = Agent{
+			Model:     model,
+			MaxTokens: maxTokens,
+		}
+		return true
+	}
+
 	return false
 }
 

+ 46 - 0
internal/format/format.go

@@ -0,0 +1,46 @@
+package format
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// OutputFormat represents the format for non-interactive mode output
+type OutputFormat string
+
+const (
+	// TextFormat is plain text output (default)
+	TextFormat OutputFormat = "text"
+
+	// JSONFormat is output wrapped in a JSON object
+	JSONFormat OutputFormat = "json"
+)
+
+// IsValid checks if the output format is valid
+func (f OutputFormat) IsValid() bool {
+	return f == TextFormat || f == JSONFormat
+}
+
+// String returns the string representation of the output format
+func (f OutputFormat) String() string {
+	return string(f)
+}
+
+// FormatOutput formats the given content according to the specified format
+func FormatOutput(content string, format OutputFormat) (string, error) {
+	switch format {
+	case TextFormat:
+		return content, nil
+	case JSONFormat:
+		jsonData := map[string]string{
+			"response": content,
+		}
+		jsonBytes, err := json.MarshalIndent(jsonData, "", "  ")
+		if err != nil {
+			return "", fmt.Errorf("failed to marshal JSON: %w", err)
+		}
+		return string(jsonBytes), nil
+	default:
+		return "", fmt.Errorf("unsupported output format: %s", format)
+	}
+}

+ 90 - 0
internal/format/format_test.go

@@ -0,0 +1,90 @@
+package format
+
+import (
+	"testing"
+)
+
+func TestOutputFormat_IsValid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name   string
+		format OutputFormat
+		want   bool
+	}{
+		{
+			name:   "text format",
+			format: TextFormat,
+			want:   true,
+		},
+		{
+			name:   "json format",
+			format: JSONFormat,
+			want:   true,
+		},
+		{
+			name:   "invalid format",
+			format: "invalid",
+			want:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			if got := tt.format.IsValid(); got != tt.want {
+				t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestFormatOutput(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		content string
+		format  OutputFormat
+		want    string
+		wantErr bool
+	}{
+		{
+			name:    "text format",
+			content: "test content",
+			format:  TextFormat,
+			want:    "test content",
+			wantErr: false,
+		},
+		{
+			name:    "json format",
+			content: "test content",
+			format:  JSONFormat,
+			want:    "{\n  \"response\": \"test content\"\n}",
+			wantErr: false,
+		},
+		{
+			name:    "invalid format",
+			content: "test content",
+			format:  "invalid",
+			want:    "",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got, err := FormatOutput(tt.content, tt.format)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("FormatOutput() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 1 - 11
internal/llm/agent/agent.go

@@ -4,12 +4,11 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log/slog"
 	"strings"
 	"sync"
 	"time"
 
-	"log/slog"
-
 	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/llm/models"
 	"github.com/sst/opencode/internal/llm/prompt"
@@ -522,15 +521,6 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		assistantMsg.AddToolCall(*event.ToolCall)
 		_, err := a.messages.Update(ctx, *assistantMsg)
 		return err
-	// TODO: see how to handle this
-	// case provider.EventToolUseDelta:
-	// 	tm := time.Unix(assistantMsg.UpdatedAt, 0)
-	// 	assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
-	// 	if time.Since(tm) > 1000*time.Millisecond {
-	// 		err := a.messages.Update(ctx, *assistantMsg)
-	// 		assistantMsg.UpdatedAt = time.Now().Unix()
-	// 		return err
-	// 	}
 	case provider.EventToolUseStop:
 		assistantMsg.FinishToolCall(event.ToolCall.ID)
 		_, err := a.messages.Update(ctx, *assistantMsg)

+ 25 - 0
internal/llm/models/bedrock.go

@@ -0,0 +1,25 @@
+package models
+
+const (
+	ProviderBedrock ModelProvider = "bedrock"
+
+	// Models
+	BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
+)
+
+var BedrockModels = map[ModelID]Model{
+	BedrockClaude37Sonnet: {
+		ID:                  BedrockClaude37Sonnet,
+		Name:                "Bedrock: Claude 3.7 Sonnet",
+		Provider:            ProviderBedrock,
+		APIModel:            "anthropic.claude-3-7-sonnet-20250219-v1:0",
+		CostPer1MIn:         3.0,
+		CostPer1MInCached:   3.75,
+		CostPer1MOutCached:  0.30,
+		CostPer1MOut:        15.0,
+		ContextWindow:       200_000,
+		DefaultMaxTokens:    50_000,
+		CanReason:           true,
+		SupportsAttachments: true,
+	},
+}

+ 2 - 0
internal/llm/models/groq.go

@@ -41,6 +41,7 @@ var GroqModels = map[ModelID]Model{
 		CostPer1MInCached:   0,
 		CostPer1MOutCached:  0,
 		CostPer1MOut:        0.34,
+		DefaultMaxTokens:    8192,
 		ContextWindow:       128_000, // 10M when?
 		SupportsAttachments: true,
 	},
@@ -54,6 +55,7 @@ var GroqModels = map[ModelID]Model{
 		CostPer1MInCached:   0,
 		CostPer1MOutCached:  0,
 		CostPer1MOut:        0.20,
+		DefaultMaxTokens:    8192,
 		ContextWindow:       128_000,
 		SupportsAttachments: true,
 	},

+ 4 - 48
internal/llm/models/models.go

@@ -22,14 +22,7 @@ type Model struct {
 	SupportsAttachments bool          `json:"supports_attachments"`
 }
 
-// Model IDs
-const ( // GEMINI
-	// Bedrock
-	BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
-)
-
 const (
-	ProviderBedrock ModelProvider = "bedrock"
 	// ForTests
 	ProviderMock ModelProvider = "__mock"
 )
@@ -43,56 +36,19 @@ var ProviderPopularity = map[ModelProvider]int{
 	ProviderOpenRouter: 5,
 	ProviderBedrock:    6,
 	ProviderAzure:      7,
+	ProviderVertexAI:   8,
 }
 
-var SupportedModels = map[ModelID]Model{
-	//
-	// // GEMINI
-	// GEMINI25: {
-	// 	ID:                 GEMINI25,
-	// 	Name:               "Gemini 2.5 Pro",
-	// 	Provider:           ProviderGemini,
-	// 	APIModel:           "gemini-2.5-pro-exp-03-25",
-	// 	CostPer1MIn:        0,
-	// 	CostPer1MInCached:  0,
-	// 	CostPer1MOutCached: 0,
-	// 	CostPer1MOut:       0,
-	// },
-	//
-	// GRMINI20Flash: {
-	// 	ID:                 GRMINI20Flash,
-	// 	Name:               "Gemini 2.0 Flash",
-	// 	Provider:           ProviderGemini,
-	// 	APIModel:           "gemini-2.0-flash",
-	// 	CostPer1MIn:        0.1,
-	// 	CostPer1MInCached:  0,
-	// 	CostPer1MOutCached: 0.025,
-	// 	CostPer1MOut:       0.4,
-	// },
-	//
-	// // Bedrock
-	BedrockClaude37Sonnet: {
-		ID:                  BedrockClaude37Sonnet,
-		Name:                "Bedrock: Claude 3.7 Sonnet",
-		Provider:            ProviderBedrock,
-		APIModel:            "anthropic.claude-3-7-sonnet-20250219-v1:0",
-		CostPer1MIn:         3.0,
-		CostPer1MInCached:   3.75,
-		CostPer1MOutCached:  0.30,
-		CostPer1MOut:        15.0,
-		ContextWindow:       200_000,
-		DefaultMaxTokens:    50_000,
-		CanReason:           true,
-		SupportsAttachments: true,
-	},
-}
+var SupportedModels = map[ModelID]Model{}
 
 func init() {
 	maps.Copy(SupportedModels, AnthropicModels)
+	maps.Copy(SupportedModels, BedrockModels)
 	maps.Copy(SupportedModels, OpenAIModels)
 	maps.Copy(SupportedModels, GeminiModels)
 	maps.Copy(SupportedModels, GroqModels)
 	maps.Copy(SupportedModels, AzureModels)
 	maps.Copy(SupportedModels, OpenRouterModels)
 	maps.Copy(SupportedModels, XAIModels)
+	maps.Copy(SupportedModels, VertexAIGeminiModels)
 }

+ 15 - 0
internal/llm/models/openai.go

@@ -3,6 +3,7 @@ package models
 const (
 	ProviderOpenAI ModelProvider = "openai"
 
+	CodexMini    ModelID = "codex-mini"
 	GPT41        ModelID = "gpt-4.1"
 	GPT41Mini    ModelID = "gpt-4.1-mini"
 	GPT41Nano    ModelID = "gpt-4.1-nano"
@@ -18,6 +19,20 @@ const (
 )
 
 var OpenAIModels = map[ModelID]Model{
+	CodexMini: {
+		ID:                  CodexMini,
+		Name:                "Codex Mini",
+		Provider:            ProviderOpenAI,
+		APIModel:            "codex-mini-latest",
+		CostPer1MIn:         1.50,
+		CostPer1MInCached:   0.375,
+		CostPer1MOutCached:  0.0,
+		CostPer1MOut:        6.00,
+		ContextWindow:       200_000,
+		DefaultMaxTokens:    100_000,
+		CanReason:           true,
+		SupportsAttachments: true,
+	},
 	GPT41: {
 		ID:                  GPT41,
 		Name:                "GPT 4.1",

+ 38 - 0
internal/llm/models/vertexai.go

@@ -0,0 +1,38 @@
+package models
+
+const (
+	ProviderVertexAI ModelProvider = "vertexai"
+
+	// Models
+	VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
+	VertexAIGemini25      ModelID = "vertexai.gemini-2.5"
+)
+
+var VertexAIGeminiModels = map[ModelID]Model{
+	VertexAIGemini25Flash: {
+		ID:                  VertexAIGemini25Flash,
+		Name:                "VertexAI: Gemini 2.5 Flash",
+		Provider:            ProviderVertexAI,
+		APIModel:            "gemini-2.5-flash-preview-04-17",
+		CostPer1MIn:         GeminiModels[Gemini25Flash].CostPer1MIn,
+		CostPer1MInCached:   GeminiModels[Gemini25Flash].CostPer1MInCached,
+		CostPer1MOut:        GeminiModels[Gemini25Flash].CostPer1MOut,
+		CostPer1MOutCached:  GeminiModels[Gemini25Flash].CostPer1MOutCached,
+		ContextWindow:       GeminiModels[Gemini25Flash].ContextWindow,
+		DefaultMaxTokens:    GeminiModels[Gemini25Flash].DefaultMaxTokens,
+		SupportsAttachments: true,
+	},
+	VertexAIGemini25: {
+		ID:                  VertexAIGemini25,
+		Name:                "VertexAI: Gemini 2.5 Pro",
+		Provider:            ProviderVertexAI,
+		APIModel:            "gemini-2.5-pro-preview-03-25",
+		CostPer1MIn:         GeminiModels[Gemini25].CostPer1MIn,
+		CostPer1MInCached:   GeminiModels[Gemini25].CostPer1MInCached,
+		CostPer1MOut:        GeminiModels[Gemini25].CostPer1MOut,
+		CostPer1MOutCached:  GeminiModels[Gemini25].CostPer1MOutCached,
+		ContextWindow:       GeminiModels[Gemini25].ContextWindow,
+		DefaultMaxTokens:    GeminiModels[Gemini25].DefaultMaxTokens,
+		SupportsAttachments: true,
+	},
+}

+ 12 - 6
internal/llm/provider/gemini.go

@@ -176,13 +176,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
 
 	history := geminiMessages[:len(geminiMessages)-1] // All but last message
 	lastMsg := geminiMessages[len(geminiMessages)-1]
-	chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
+	config := &genai.GenerateContentConfig{
 		MaxOutputTokens: int32(g.providerOptions.maxTokens),
 		SystemInstruction: &genai.Content{
 			Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
 		},
-		Tools: g.convertTools(tools),
-	}, history)
+	}
+	if len(tools) > 0 {
+		config.Tools = g.convertTools(tools)
+	}
+	chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
 
 	attempts := 0
 	for {
@@ -262,13 +265,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
 
 	history := geminiMessages[:len(geminiMessages)-1] // All but last message
 	lastMsg := geminiMessages[len(geminiMessages)-1]
-	chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
+	config := &genai.GenerateContentConfig{
 		MaxOutputTokens: int32(g.providerOptions.maxTokens),
 		SystemInstruction: &genai.Content{
 			Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
 		},
-		Tools: g.convertTools(tools),
-	}, history)
+	}
+	if len(tools) > 0 {
+		config.Tools = g.convertTools(tools)
+	}
+	chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
 
 	attempts := 0
 	eventChan := make(chan ProviderEvent)

+ 10 - 297
internal/llm/provider/openai.go

@@ -2,21 +2,14 @@ package provider
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
-	"time"
-
+	"log/slog"
 	"github.com/openai/openai-go"
 	"github.com/openai/openai-go/option"
-	"github.com/openai/openai-go/shared"
-	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/llm/models"
 	"github.com/sst/opencode/internal/llm/tools"
 	"github.com/sst/opencode/internal/message"
-	"github.com/sst/opencode/internal/status"
-	"log/slog"
 )
 
 type openaiOptions struct {
@@ -66,87 +59,21 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient {
 	}
 }
 
-func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
-	// Add system message first
-	openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
-
-	for _, msg := range messages {
-		switch msg.Role {
-		case message.User:
-			var content []openai.ChatCompletionContentPartUnionParam
-			textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
-			content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
-			for _, binaryContent := range msg.BinaryContent() {
-				imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
-				imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
-
-				content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
-			}
-
-			openaiMessages = append(openaiMessages, openai.UserMessage(content))
-
-		case message.Assistant:
-			assistantMsg := openai.ChatCompletionAssistantMessageParam{
-				Role: "assistant",
-			}
-
-			if msg.Content().String() != "" {
-				assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
-					OfString: openai.String(msg.Content().String()),
-				}
-			}
-
-			if len(msg.ToolCalls()) > 0 {
-				assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
-				for i, call := range msg.ToolCalls() {
-					assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
-						ID:   call.ID,
-						Type: "function",
-						Function: openai.ChatCompletionMessageToolCallFunctionParam{
-							Name:      call.Name,
-							Arguments: call.Input,
-						},
-					}
-				}
-			}
-
-			openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
-				OfAssistant: &assistantMsg,
-			})
-
-		case message.Tool:
-			for _, result := range msg.ToolResults() {
-				openaiMessages = append(openaiMessages,
-					openai.ToolMessage(result.Content, result.ToolCallID),
-				)
-			}
-		}
+func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
+	if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID {
+		return o.sendResponseMessages(ctx, messages, tools)
 	}
-
-	return
+	return o.sendChatcompletionMessage(ctx, messages, tools)
 }
 
-func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
-	openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
-
-	for i, tool := range tools {
-		info := tool.Info()
-		openaiTools[i] = openai.ChatCompletionToolParam{
-			Function: openai.FunctionDefinitionParam{
-				Name:        info.Name,
-				Description: openai.String(info.Description),
-				Parameters: openai.FunctionParameters{
-					"type":       "object",
-					"properties": info.Parameters,
-					"required":   info.Required,
-				},
-			},
-		}
+func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
+	if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID {
+		return o.streamResponseMessages(ctx, messages, tools)
 	}
-
-	return openaiTools
+	return o.streamChatCompletionMessages(ctx, messages, tools)
 }
 
+
 func (o *openaiClient) finishReason(reason string) message.FinishReason {
 	switch reason {
 	case "stop":
@@ -160,190 +87,6 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
 	}
 }
 
-func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
-	params := openai.ChatCompletionNewParams{
-		Model:    openai.ChatModel(o.providerOptions.model.APIModel),
-		Messages: messages,
-		Tools:    tools,
-	}
-
-	if o.providerOptions.model.CanReason == true {
-		params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
-		switch o.options.reasoningEffort {
-		case "low":
-			params.ReasoningEffort = shared.ReasoningEffortLow
-		case "medium":
-			params.ReasoningEffort = shared.ReasoningEffortMedium
-		case "high":
-			params.ReasoningEffort = shared.ReasoningEffortHigh
-		default:
-			params.ReasoningEffort = shared.ReasoningEffortMedium
-		}
-	} else {
-		params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
-	}
-
-	if o.providerOptions.model.Provider == models.ProviderOpenRouter {
-		params.WithExtraFields(map[string]any{
-			"provider": map[string]any{
-				"require_parameters": true,
-			},
-		})
-	}
-
-	return params
-}
-
-func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	cfg := config.Get()
-	if cfg.Debug {
-		jsonData, _ := json.Marshal(params)
-		slog.Debug("Prepared messages", "messages", string(jsonData))
-	}
-	attempts := 0
-	for {
-		attempts++
-		openaiResponse, err := o.client.Chat.Completions.New(
-			ctx,
-			params,
-		)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			duration := time.Duration(after) * time.Millisecond
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(duration):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		content := ""
-		if openaiResponse.Choices[0].Message.Content != "" {
-			content = openaiResponse.Choices[0].Message.Content
-		}
-
-		toolCalls := o.toolCalls(*openaiResponse)
-		finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
-
-		if len(toolCalls) > 0 {
-			finishReason = message.FinishReasonToolUse
-		}
-
-		return &ProviderResponse{
-			Content:      content,
-			ToolCalls:    toolCalls,
-			Usage:        o.usage(*openaiResponse),
-			FinishReason: finishReason,
-		}, nil
-	}
-}
-
-func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
-		IncludeUsage: openai.Bool(true),
-	}
-
-	cfg := config.Get()
-	if cfg.Debug {
-		jsonData, _ := json.Marshal(params)
-		slog.Debug("Prepared messages", "messages", string(jsonData))
-	}
-
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-
-	go func() {
-		for {
-			attempts++
-			openaiStream := o.client.Chat.Completions.NewStreaming(
-				ctx,
-				params,
-			)
-
-			acc := openai.ChatCompletionAccumulator{}
-			currentContent := ""
-			toolCalls := make([]message.ToolCall, 0)
-
-			for openaiStream.Next() {
-				chunk := openaiStream.Current()
-				acc.AddChunk(chunk)
-
-				for _, choice := range chunk.Choices {
-					if choice.Delta.Content != "" {
-						eventChan <- ProviderEvent{
-							Type:    EventContentDelta,
-							Content: choice.Delta.Content,
-						}
-						currentContent += choice.Delta.Content
-					}
-				}
-			}
-
-			err := openaiStream.Err()
-			if err == nil || errors.Is(err, io.EOF) {
-				// Stream completed successfully
-				finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
-				if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
-					toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
-				}
-				if len(toolCalls) > 0 {
-					finishReason = message.FinishReasonToolUse
-				}
-
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      currentContent,
-						ToolCalls:    toolCalls,
-						Usage:        o.usage(acc.ChatCompletion),
-						FinishReason: finishReason,
-					},
-				}
-				close(eventChan)
-				return
-			}
-
-			// If there is an error we are going to see if we can retry the call
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			duration := time.Duration(after) * time.Millisecond
-			if retryErr != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-				close(eventChan)
-				return
-			}
-			if retry {
-				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
-				select {
-				case <-ctx.Done():
-					// context cancelled
-					if ctx.Err() == nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					}
-					close(eventChan)
-					return
-				case <-time.After(duration):
-					continue
-				}
-			}
-			eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-			close(eventChan)
-			return
-		}
-	}()
-
-	return eventChan
-}
 
 func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
 	var apierr *openai.Error
@@ -373,36 +116,6 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
 	return true, int64(retryMs), nil
 }
 
-func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
-	var toolCalls []message.ToolCall
-
-	if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
-		for _, call := range completion.Choices[0].Message.ToolCalls {
-			toolCall := message.ToolCall{
-				ID:       call.ID,
-				Name:     call.Function.Name,
-				Input:    call.Function.Arguments,
-				Type:     "function",
-				Finished: true,
-			}
-			toolCalls = append(toolCalls, toolCall)
-		}
-	}
-
-	return toolCalls
-}
-
-func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
-	cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
-	inputTokens := completion.Usage.PromptTokens - cachedTokens
-
-	return TokenUsage{
-		InputTokens:         inputTokens,
-		OutputTokens:        completion.Usage.CompletionTokens,
-		CacheCreationTokens: 0, // OpenAI doesn't provide this directly
-		CacheReadTokens:     cachedTokens,
-	}
-}
 
 func WithOpenAIBaseURL(baseURL string) OpenAIOption {
 	return func(options *openaiOptions) {

+ 317 - 0
internal/llm/provider/openai_completion.go

@@ -0,0 +1,317 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"time"
+
+	"github.com/openai/openai-go"
+	"github.com/openai/openai-go/shared"
+	"github.com/sst/opencode/internal/config"
+	"github.com/sst/opencode/internal/llm/models"
+	"github.com/sst/opencode/internal/llm/tools"
+	"github.com/sst/opencode/internal/message"
+	"github.com/sst/opencode/internal/status"
+)
+
+func (o *openaiClient) convertMessagesToChatCompletionMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
+	// Add system message first
+	openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
+
+	for _, msg := range messages {
+		switch msg.Role {
+		case message.User:
+			var content []openai.ChatCompletionContentPartUnionParam
+			textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
+			content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
+			for _, binaryContent := range msg.BinaryContent() {
+				imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
+				imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
+
+				content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
+			}
+
+			openaiMessages = append(openaiMessages, openai.UserMessage(content))
+
+		case message.Assistant:
+			assistantMsg := openai.ChatCompletionAssistantMessageParam{
+				Role: "assistant",
+			}
+
+			if msg.Content().String() != "" {
+				assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
+					OfString: openai.String(msg.Content().String()),
+				}
+			}
+
+			if len(msg.ToolCalls()) > 0 {
+				assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
+				for i, call := range msg.ToolCalls() {
+					assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
+						ID:   call.ID,
+						Type: "function",
+						Function: openai.ChatCompletionMessageToolCallFunctionParam{
+							Name:      call.Name,
+							Arguments: call.Input,
+						},
+					}
+				}
+			}
+
+			openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
+				OfAssistant: &assistantMsg,
+			})
+
+		case message.Tool:
+			for _, result := range msg.ToolResults() {
+				openaiMessages = append(openaiMessages,
+					openai.ToolMessage(result.Content, result.ToolCallID),
+				)
+			}
+		}
+	}
+
+	return
+}
+
+func (o *openaiClient) convertToChatCompletionTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
+	openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
+
+	for i, tool := range tools {
+		info := tool.Info()
+		openaiTools[i] = openai.ChatCompletionToolParam{
+			Function: openai.FunctionDefinitionParam{
+				Name:        info.Name,
+				Description: openai.String(info.Description),
+				Parameters: openai.FunctionParameters{
+					"type":       "object",
+					"properties": info.Parameters,
+					"required":   info.Required,
+				},
+			},
+		}
+	}
+
+	return openaiTools
+}
+
+func (o *openaiClient) preparedChatCompletionParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
+	params := openai.ChatCompletionNewParams{
+		Model:    openai.ChatModel(o.providerOptions.model.APIModel),
+		Messages: messages,
+		Tools:    tools,
+	}
+	if o.providerOptions.model.CanReason == true {
+		params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
+		switch o.options.reasoningEffort {
+		case "low":
+			params.ReasoningEffort = shared.ReasoningEffortLow
+		case "medium":
+			params.ReasoningEffort = shared.ReasoningEffortMedium
+		case "high":
+			params.ReasoningEffort = shared.ReasoningEffortHigh
+		default:
+			params.ReasoningEffort = shared.ReasoningEffortMedium
+		}
+	} else {
+		params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
+	}
+
+	if o.providerOptions.model.Provider == models.ProviderOpenRouter {
+		params.WithExtraFields(map[string]any{
+			"provider": map[string]any{
+				"require_parameters": true,
+			},
+		})
+	}
+
+	return params
+}
+
+func (o *openaiClient) sendChatcompletionMessage(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
+	params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools))
+	cfg := config.Get()
+	if cfg.Debug {
+		jsonData, _ := json.Marshal(params)
+		slog.Debug("Prepared messages", "messages", string(jsonData))
+	}
+	attempts := 0
+	for {
+		attempts++
+		openaiResponse, err := o.client.Chat.Completions.New(
+			ctx,
+			params,
+		)
+		// If there is an error we are going to see if we can retry the call
+		if err != nil {
+			retry, after, retryErr := o.shouldRetry(attempts, err)
+			duration := time.Duration(after) * time.Millisecond
+			if retryErr != nil {
+				return nil, retryErr
+			}
+			if retry {
+				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
+				select {
+				case <-ctx.Done():
+					return nil, ctx.Err()
+				case <-time.After(duration):
+					continue
+				}
+			}
+			return nil, retryErr
+		}
+
+		content := ""
+		if openaiResponse.Choices[0].Message.Content != "" {
+			content = openaiResponse.Choices[0].Message.Content
+		}
+
+		toolCalls := o.chatCompletionToolCalls(*openaiResponse)
+		finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
+
+		if len(toolCalls) > 0 {
+			finishReason = message.FinishReasonToolUse
+		}
+
+		return &ProviderResponse{
+			Content:      content,
+			ToolCalls:    toolCalls,
+			Usage:        o.usage(*openaiResponse),
+			FinishReason: finishReason,
+		}, nil
+	}
+}
+
+func (o *openaiClient) streamChatCompletionMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
+	params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools))
+	params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
+		IncludeUsage: openai.Bool(true),
+	}
+
+	cfg := config.Get()
+	if cfg.Debug {
+		jsonData, _ := json.Marshal(params)
+		slog.Debug("Prepared messages", "messages", string(jsonData))
+	}
+
+	attempts := 0
+	eventChan := make(chan ProviderEvent)
+
+	go func() {
+		for {
+			attempts++
+			openaiStream := o.client.Chat.Completions.NewStreaming(
+				ctx,
+				params,
+			)
+
+			acc := openai.ChatCompletionAccumulator{}
+			currentContent := ""
+			toolCalls := make([]message.ToolCall, 0)
+
+			for openaiStream.Next() {
+				chunk := openaiStream.Current()
+				acc.AddChunk(chunk)
+
+				for _, choice := range chunk.Choices {
+					if choice.Delta.Content != "" {
+						eventChan <- ProviderEvent{
+							Type:    EventContentDelta,
+							Content: choice.Delta.Content,
+						}
+						currentContent += choice.Delta.Content
+					}
+				}
+			}
+
+			err := openaiStream.Err()
+			if err == nil || errors.Is(err, io.EOF) {
+				// Stream completed successfully
+				finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
+				if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
+					toolCalls = append(toolCalls, o.chatCompletionToolCalls(acc.ChatCompletion)...)
+				}
+				if len(toolCalls) > 0 {
+					finishReason = message.FinishReasonToolUse
+				}
+
+				eventChan <- ProviderEvent{
+					Type: EventComplete,
+					Response: &ProviderResponse{
+						Content:      currentContent,
+						ToolCalls:    toolCalls,
+						Usage:        o.usage(acc.ChatCompletion),
+						FinishReason: finishReason,
+					},
+				}
+				close(eventChan)
+				return
+			}
+
+			// If there is an error we are going to see if we can retry the call
+			retry, after, retryErr := o.shouldRetry(attempts, err)
+			duration := time.Duration(after) * time.Millisecond
+			if retryErr != nil {
+				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
+				close(eventChan)
+				return
+			}
+			if retry {
+				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
+				select {
+				case <-ctx.Done():
+					// context cancelled
+					if ctx.Err() == nil {
+						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
+					}
+					close(eventChan)
+					return
+				case <-time.After(duration):
+					continue
+				}
+			}
+			eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
+			close(eventChan)
+			return
+		}
+	}()
+
+	return eventChan
+}
+
+
+func (o *openaiClient) chatCompletionToolCalls(completion openai.ChatCompletion) []message.ToolCall {
+	var toolCalls []message.ToolCall
+
+	if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
+		for _, call := range completion.Choices[0].Message.ToolCalls {
+			toolCall := message.ToolCall{
+				ID:       call.ID,
+				Name:     call.Function.Name,
+				Input:    call.Function.Arguments,
+				Type:     "function",
+				Finished: true,
+			}
+			toolCalls = append(toolCalls, toolCall)
+		}
+	}
+
+	return toolCalls
+}
+
+func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
+	cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
+	inputTokens := completion.Usage.PromptTokens - cachedTokens
+
+	return TokenUsage{
+		InputTokens:         inputTokens,
+		OutputTokens:        completion.Usage.CompletionTokens,
+		CacheCreationTokens: 0, // OpenAI doesn't provide this directly
+		CacheReadTokens:     cachedTokens,
+	}
+}
+

+ 393 - 0
internal/llm/provider/openai_response.go

@@ -0,0 +1,393 @@
+package provider
+
+
+import (
+	"github.com/openai/openai-go"
+	"github.com/openai/openai-go/responses"
+	"github.com/sst/opencode/internal/llm/models"
+	"github.com/sst/opencode/internal/llm/tools"
+	"github.com/sst/opencode/internal/message"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"time"
+
+	"log/slog"
+
+	"github.com/openai/openai-go/shared"
+	"github.com/sst/opencode/internal/config"
+	"github.com/sst/opencode/internal/status"
+)
+
+func (o *openaiClient) convertMessagesToResponseParams(messages []message.Message) responses.ResponseInputParam {
+	inputItems := responses.ResponseInputParam{}
+
+	inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
+		OfMessage: &responses.EasyInputMessageParam{
+			Content: responses.EasyInputMessageContentUnionParam{OfString: openai.String(o.providerOptions.systemMessage)},
+			Role:    responses.EasyInputMessageRoleSystem,
+		},
+	})
+
+	for _, msg := range messages {
+		switch msg.Role {
+		case message.User:
+			inputItemContentList := responses.ResponseInputMessageContentListParam{
+				responses.ResponseInputContentUnionParam{
+					OfInputText: &responses.ResponseInputTextParam{
+						Text: msg.Content().String(),
+					},
+				},
+			}
+
+			for _, binaryContent := range msg.BinaryContent() {
+				inputItemContentList = append(inputItemContentList, responses.ResponseInputContentUnionParam{
+					OfInputImage: &responses.ResponseInputImageParam{
+						ImageURL: openai.String(binaryContent.String(models.ProviderOpenAI)),
+					},
+				})
+			}
+
+			userMsg := responses.ResponseInputItemUnionParam{
+				OfInputMessage: &responses.ResponseInputItemMessageParam{
+					Content: inputItemContentList,
+					Role:    string(responses.ResponseInputMessageItemRoleUser),
+				},
+			}
+			inputItems = append(inputItems, userMsg)
+
+		case message.Assistant:
+			if msg.Content().String() != "" {
+				assistantMsg := responses.ResponseInputItemUnionParam{
+					OfOutputMessage: &responses.ResponseOutputMessageParam{
+						Content: []responses.ResponseOutputMessageContentUnionParam{{
+							OfOutputText: &responses.ResponseOutputTextParam{
+								Text: msg.Content().String(),
+							},
+						}},
+					},
+				}
+				inputItems = append(inputItems, assistantMsg)
+			}
+
+			if len(msg.ToolCalls()) > 0 {
+				for _, call := range msg.ToolCalls() {
+					toolMsg := responses.ResponseInputItemUnionParam{
+						OfFunctionCall: &responses.ResponseFunctionToolCallParam{
+							CallID:    call.ID,
+							Name:      call.Name,
+							Arguments: call.Input,
+						},
+					}
+					inputItems = append(inputItems, toolMsg)
+				}
+			}
+
+		case message.Tool:
+			for _, result := range msg.ToolResults() {
+				toolMsg := responses.ResponseInputItemUnionParam{
+					OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
+						Output: result.Content,
+						CallID: result.ToolCallID,
+					},
+				}
+				inputItems = append(inputItems, toolMsg)
+			}
+		}
+	}
+
+	return inputItems
+}
+
+func (o *openaiClient) convertToResponseTools(tools []tools.BaseTool) []responses.ToolUnionParam {
+	outputTools := make([]responses.ToolUnionParam, len(tools))
+
+	for i, tool := range tools {
+		info := tool.Info()
+		outputTools[i] = responses.ToolUnionParam{
+			OfFunction: &responses.FunctionToolParam{
+				Name:        info.Name,
+				Description: openai.String(info.Description),
+				Parameters: map[string]any{
+					"type":       "object",
+					"properties": info.Parameters,
+					"required":   info.Required,
+				},
+			},
+		}
+	}
+
+	return outputTools
+}
+
+
+func (o *openaiClient) preparedResponseParams(input responses.ResponseInputParam, tools []responses.ToolUnionParam) responses.ResponseNewParams {
+	params := responses.ResponseNewParams{
+		Model: shared.ResponsesModel(o.providerOptions.model.APIModel),
+		Input: responses.ResponseNewParamsInputUnion{OfInputItemList: input},
+		Tools: tools,
+	}
+
+	params.MaxOutputTokens = openai.Int(o.providerOptions.maxTokens)
+
+	if o.providerOptions.model.CanReason == true {
+		switch o.options.reasoningEffort {
+		case "low":
+			params.Reasoning.Effort = shared.ReasoningEffortLow
+		case "medium":
+			params.Reasoning.Effort = shared.ReasoningEffortMedium
+		case "high":
+			params.Reasoning.Effort = shared.ReasoningEffortHigh
+		default:
+			params.Reasoning.Effort = shared.ReasoningEffortMedium
+		}
+	}
+
+	if o.providerOptions.model.Provider == models.ProviderOpenRouter {
+		params.WithExtraFields(map[string]any{
+			"provider": map[string]any{
+				"require_parameters": true,
+			},
+		})
+	}
+
+	return params
+}
+
+func (o *openaiClient) sendResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
+	params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools))
+	cfg := config.Get()
+	if cfg.Debug {
+		jsonData, _ := json.Marshal(params)
+		slog.Debug("Prepared messages", "messages", string(jsonData))
+	}
+	attempts := 0
+	for {
+		attempts++
+		openaiResponse, err := o.client.Responses.New(
+			ctx,
+			params,
+		)
+		// If there is an error we are going to see if we can retry the call
+		if err != nil {
+			retry, after, retryErr := o.shouldRetry(attempts, err)
+			duration := time.Duration(after) * time.Millisecond
+			if retryErr != nil {
+				return nil, retryErr
+			}
+			if retry {
+				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
+				select {
+				case <-ctx.Done():
+					return nil, ctx.Err()
+				case <-time.After(duration):
+					continue
+				}
+			}
+			return nil, retryErr
+		}
+
+		content := ""
+		if openaiResponse.OutputText() != "" {
+			content = openaiResponse.OutputText()
+		}
+
+		toolCalls := o.responseToolCalls(*openaiResponse)
+		finishReason := o.finishReason("stop")
+
+		if len(toolCalls) > 0 {
+			finishReason = message.FinishReasonToolUse
+		}
+
+		return &ProviderResponse{
+			Content:      content,
+			ToolCalls:    toolCalls,
+			Usage:        o.responseUsage(*openaiResponse),
+			FinishReason: finishReason,
+		}, nil
+	}
+}
+
+func (o *openaiClient) streamResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
+	eventChan := make(chan ProviderEvent)
+
+	params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools))
+
+	cfg := config.Get()
+	if cfg.Debug {
+		jsonData, _ := json.Marshal(params)
+		slog.Debug("Prepared messages", "messages", string(jsonData))
+	}
+
+	attempts := 0
+
+	go func() {
+		for {
+			attempts++
+			stream := o.client.Responses.NewStreaming(ctx, params)
+			outputText := ""
+			currentToolCallID := ""
+			for stream.Next() {
+				event := stream.Current()
+
+				switch event := event.AsAny().(type) {
+				case responses.ResponseCompletedEvent:
+					toolCalls := o.responseToolCalls(event.Response)
+					finishReason := o.finishReason("stop")
+
+					if len(toolCalls) > 0 {
+						finishReason = message.FinishReasonToolUse
+					}
+
+					eventChan <- ProviderEvent{
+						Type: EventComplete,
+						Response: &ProviderResponse{
+							Content:      outputText,
+							ToolCalls:    toolCalls,
+							Usage:        o.responseUsage(event.Response),
+							FinishReason: finishReason,
+						},
+					}
+					close(eventChan)
+					return
+
+				case responses.ResponseTextDeltaEvent:
+					outputText += event.Delta
+					eventChan <- ProviderEvent{
+						Type:    EventContentDelta,
+						Content: event.Delta,
+					}
+
+				case responses.ResponseTextDoneEvent:
+					eventChan <- ProviderEvent{
+						Type:    EventContentStop,
+						Content: outputText,
+					}
+					close(eventChan)
+					return
+
+				case responses.ResponseOutputItemAddedEvent:
+					if event.Item.Type == "function_call" {
+						currentToolCallID = event.Item.ID
+						eventChan <- ProviderEvent{
+							Type: EventToolUseStart,
+							ToolCall: &message.ToolCall{
+								ID:       event.Item.ID,
+								Name:     event.Item.Name,
+								Finished: false,
+							},
+						}
+					}
+
+				case responses.ResponseFunctionCallArgumentsDeltaEvent:
+					if event.ItemID == currentToolCallID {
+						eventChan <- ProviderEvent{
+							Type: EventToolUseDelta,
+							ToolCall: &message.ToolCall{
+								ID:       currentToolCallID,
+								Finished: false,
+								Input:    event.Delta,
+							},
+						}
+					}
+
+				case responses.ResponseFunctionCallArgumentsDoneEvent:
+					if event.ItemID == currentToolCallID {
+						eventChan <- ProviderEvent{
+							Type: EventToolUseStop,
+							ToolCall: &message.ToolCall{
+								ID:    currentToolCallID,
+								Input: event.Arguments,
+							},
+						}
+						currentToolCallID = ""
+					}
+
+				case responses.ResponseOutputItemDoneEvent:
+					if event.Item.Type == "function_call" {
+						eventChan <- ProviderEvent{
+							Type: EventToolUseStop,
+							ToolCall: &message.ToolCall{
+								ID:       event.Item.ID,
+								Name:     event.Item.Name,
+								Input:    event.Item.Arguments,
+								Finished: true,
+							},
+						}
+						currentToolCallID = ""
+					}
+
+				}
+			}
+
+			err := stream.Err()
+			if err == nil || errors.Is(err, io.EOF) {
+				close(eventChan)
+				return
+			}
+
+			// If there is an error we are going to see if we can retry the call
+			retry, after, retryErr := o.shouldRetry(attempts, err)
+			duration := time.Duration(after) * time.Millisecond
+			if retryErr != nil {
+				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
+				close(eventChan)
+				return
+			}
+			if retry {
+				status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
+				select {
+				case <-ctx.Done():
+					// context cancelled
+					if ctx.Err() == nil {
+						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
+					}
+					close(eventChan)
+					return
+				case <-time.After(duration):
+					continue
+				}
+			}
+			eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
+			close(eventChan)
+			return
+		}
+	}()
+
+	return eventChan
+}
+
+
+func (o *openaiClient) responseToolCalls(response responses.Response) []message.ToolCall {
+	var toolCalls []message.ToolCall
+
+	for _, output := range response.Output {
+		if output.Type == "function_call" {
+			call := output.AsFunctionCall()
+			toolCall := message.ToolCall{
+				ID:       call.ID,
+				Name:     call.Name,
+				Input:    call.Arguments,
+				Type:     "function",
+				Finished: true,
+			}
+			toolCalls = append(toolCalls, toolCall)
+		}
+	}
+
+	return toolCalls
+}
+
+func (o *openaiClient) responseUsage(response responses.Response) TokenUsage {
+	cachedTokens := response.Usage.InputTokensDetails.CachedTokens
+	inputTokens := response.Usage.InputTokens - cachedTokens
+
+	return TokenUsage{
+		InputTokens:         inputTokens,
+		OutputTokens:        response.Usage.OutputTokens,
+		CacheCreationTokens: 0, // OpenAI doesn't provide this directly
+		CacheReadTokens:     cachedTokens,
+	}
+}

+ 6 - 1
internal/llm/provider/provider.go

@@ -3,11 +3,11 @@ package provider
 import (
 	"context"
 	"fmt"
+	"log/slog"
 
 	"github.com/sst/opencode/internal/llm/models"
 	"github.com/sst/opencode/internal/llm/tools"
 	"github.com/sst/opencode/internal/message"
-	"log/slog"
 )
 
 type EventType string
@@ -123,6 +123,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
 			options: clientOptions,
 			client:  newAzureClient(clientOptions),
 		}, nil
+	case models.ProviderVertexAI:
+		return &baseProvider[VertexAIClient]{
+			options: clientOptions,
+			client:  newVertexAIClient(clientOptions),
+		}, nil
 	case models.ProviderOpenRouter:
 		clientOptions.openaiOptions = append(clientOptions.openaiOptions,
 			WithOpenAIBaseURL("https://openrouter.ai/api/v1"),

+ 34 - 0
internal/llm/provider/vertexai.go

@@ -0,0 +1,34 @@
+package provider
+
+import (
+	"context"
+	"log/slog"
+	"os"
+
+	"google.golang.org/genai"
+)
+
+type VertexAIClient ProviderClient
+
+func newVertexAIClient(opts providerClientOptions) VertexAIClient {
+	geminiOpts := geminiOptions{}
+	for _, o := range opts.geminiOptions {
+		o(&geminiOpts)
+	}
+
+	client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+		Project:  os.Getenv("VERTEXAI_PROJECT"),
+		Location: os.Getenv("VERTEXAI_LOCATION"),
+		Backend:  genai.BackendVertexAI,
+	})
+	if err != nil {
+		slog.Error("Failed to create VertexAI client", "error", err)
+		return nil
+	}
+
+	return &geminiClient{
+		providerOptions: opts,
+		options:         geminiOpts,
+		client:          client,
+	}
+}

+ 3 - 18
internal/llm/tools/edit.go

@@ -196,16 +196,11 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
 		content,
 		filePath,
 	)
-	rootDir := config.WorkingDirectory()
-	permissionPath := filepath.Dir(filePath)
-	if strings.HasPrefix(filePath, rootDir) {
-		permissionPath = rootDir
-	}
 	p := e.permissions.Request(
 		ctx,
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
-			Path:        permissionPath,
+			Path:        filePath,
 			ToolName:    EditToolName,
 			Action:      "write",
 			Description: fmt.Sprintf("Create file %s", filePath),
@@ -308,16 +303,11 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
 		filePath,
 	)
 
-	rootDir := config.WorkingDirectory()
-	permissionPath := filepath.Dir(filePath)
-	if strings.HasPrefix(filePath, rootDir) {
-		permissionPath = rootDir
-	}
 	p := e.permissions.Request(
 		ctx,
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
-			Path:        permissionPath,
+			Path:        filePath,
 			ToolName:    EditToolName,
 			Action:      "write",
 			Description: fmt.Sprintf("Delete content from file %s", filePath),
@@ -429,16 +419,11 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
 		newContent,
 		filePath,
 	)
-	rootDir := config.WorkingDirectory()
-	permissionPath := filepath.Dir(filePath)
-	if strings.HasPrefix(filePath, rootDir) {
-		permissionPath = rootDir
-	}
 	p := e.permissions.Request(
 		ctx,
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
-			Path:        permissionPath,
+			Path:        filePath,
 			ToolName:    EditToolName,
 			Action:      "write",
 			Description: fmt.Sprintf("Replace content in file %s", filePath),

+ 20 - 4
internal/llm/tools/shell/shell.go

@@ -12,6 +12,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/status"
 )
 
@@ -59,12 +60,27 @@ func GetPersistentShell(workingDir string) *PersistentShell {
 }
 
 func newPersistentShell(cwd string) *PersistentShell {
-	shellPath := os.Getenv("SHELL")
-	if shellPath == "" {
-		shellPath = "/bin/bash"
+	cfg := config.Get()
+	
+	// Use shell from config if specified
+	shellPath := ""
+	shellArgs := []string{"-l"}
+	
+	if cfg != nil && cfg.Shell.Path != "" {
+		shellPath = cfg.Shell.Path
+		if len(cfg.Shell.Args) > 0 {
+			shellArgs = cfg.Shell.Args
+		}
+	} else {
+		// Fall back to environment variable
+		shellPath = os.Getenv("SHELL")
+		if shellPath == "" {
+			// Default to bash if neither config nor environment variable is set
+			shellPath = "/bin/bash"
+		}
 	}
 
-	cmd := exec.Command(shellPath, "-l")
+	cmd := exec.Command(shellPath, shellArgs...)
 	cmd.Dir = cwd
 
 	stdinPipe, err := cmd.StdinPipe()

+ 1 - 7
internal/llm/tools/write.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"strings"
 	"time"
 
 	"github.com/sst/opencode/internal/config"
@@ -161,16 +160,11 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
 		filePath,
 	)
 
-	rootDir := config.WorkingDirectory()
-	permissionPath := filepath.Dir(filePath)
-	if strings.HasPrefix(filePath, rootDir) {
-		permissionPath = rootDir
-	}
 	p := w.permissions.Request(
 		ctx,
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
-			Path:        permissionPath,
+			Path:        filePath,
 			ToolName:    WriteToolName,
 			Action:      "write",
 			Description: fmt.Sprintf("Create file %s", filePath),

+ 102 - 10
internal/tui/components/chat/editor.go

@@ -2,6 +2,7 @@ package chat
 
 import (
 	"fmt"
+	"log/slog"
 	"os"
 	"os/exec"
 	"slices"
@@ -16,6 +17,7 @@ import (
 	"github.com/sst/opencode/internal/message"
 	"github.com/sst/opencode/internal/status"
 	"github.com/sst/opencode/internal/tui/components/dialog"
+	"github.com/sst/opencode/internal/tui/image"
 	"github.com/sst/opencode/internal/tui/layout"
 	"github.com/sst/opencode/internal/tui/styles"
 	"github.com/sst/opencode/internal/tui/theme"
@@ -23,17 +25,23 @@ import (
 )
 
 type editorCmp struct {
-	width       int
-	height      int
-	app         *app.App
-	textarea    textarea.Model
-	attachments []message.Attachment
-	deleteMode  bool
+	width          int
+	height         int
+	app            *app.App
+	textarea       textarea.Model
+	attachments    []message.Attachment
+	deleteMode     bool
+	history        []string
+	historyIndex   int
+	currentMessage string
 }
 
 type EditorKeyMaps struct {
 	Send       key.Binding
 	OpenEditor key.Binding
+	Paste      key.Binding
+	HistoryUp  key.Binding
+	HistoryDown key.Binding
 }
 
 type bluredEditorKeyMaps struct {
@@ -56,6 +64,18 @@ var editorMaps = EditorKeyMaps{
 		key.WithKeys("ctrl+e"),
 		key.WithHelp("ctrl+e", "open editor"),
 	),
+	Paste: key.NewBinding(
+		key.WithKeys("ctrl+v"),
+		key.WithHelp("ctrl+v", "paste content"),
+	),
+	HistoryUp: key.NewBinding(
+		key.WithKeys("up"),
+		key.WithHelp("up", "previous message"),
+	),
+	HistoryDown: key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("down", "next message"),
+	),
 }
 
 var DeleteKeyMaps = DeleteAttachmentKeyMaps{
@@ -69,7 +89,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 	),
 	DeleteAllAttachments: key.NewBinding(
 		key.WithKeys("r"),
-		key.WithHelp("ctrl+r+r", "delete all attchments"),
+		key.WithHelp("ctrl+r+r", "delete all attachments"),
 	),
 }
 
@@ -132,6 +152,15 @@ func (m *editorCmp) send() tea.Cmd {
 	m.textarea.Reset()
 	attachments := m.attachments
 
+	// Save to history if not empty and not a duplicate of the last entry
+	if value != "" {
+		if len(m.history) == 0 || m.history[len(m.history)-1] != value {
+			m.history = append(m.history, value)
+		}
+		m.historyIndex = len(m.history)
+		m.currentMessage = ""
+	}
+
 	m.attachments = nil
 	if value == "" {
 		return nil
@@ -200,6 +229,67 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.deleteMode = false
 			return m, nil
 		}
+
+		if key.Matches(msg, editorMaps.Paste) {
+			imageBytes, text, err := image.GetImageFromClipboard()
+			if err != nil {
+				slog.Error(err.Error())
+				return m, cmd
+			}
+			if len(imageBytes) != 0 {
+				attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
+				attachment := message.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
+				m.attachments = append(m.attachments, attachment)
+			} else {
+				m.textarea.SetValue(m.textarea.Value() + text)
+			}
+			return m, cmd
+		}
+
+		// Handle history navigation with up/down arrow keys
+		// Only handle history navigation if the filepicker is not open
+		if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() {
+			// Get the current line number
+			currentLine := m.textarea.Line()
+			
+			// Only navigate history if we're at the first line
+			if currentLine == 0 && len(m.history) > 0 {
+				// Save current message if we're just starting to navigate
+				if m.historyIndex == len(m.history) {
+					m.currentMessage = m.textarea.Value()
+				}
+				
+				// Go to previous message in history
+				if m.historyIndex > 0 {
+					m.historyIndex--
+					m.textarea.SetValue(m.history[m.historyIndex])
+				}
+				return m, nil
+			}
+		}
+		
+		if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() {
+			// Get the current line number and total lines
+			currentLine := m.textarea.Line()
+			value := m.textarea.Value()
+			lines := strings.Split(value, "\n")
+			totalLines := len(lines)
+			
+			// Only navigate history if we're at the last line
+			if currentLine == totalLines-1 {
+				if m.historyIndex < len(m.history)-1 {
+					// Go to next message in history
+					m.historyIndex++
+					m.textarea.SetValue(m.history[m.historyIndex])
+				} else if m.historyIndex == len(m.history)-1 {
+					// Return to the current message being composed
+					m.historyIndex = len(m.history)
+					m.textarea.SetValue(m.currentMessage)
+				}
+				return m, nil
+			}
+		}
+
 		// Handle Enter key
 		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
 			value := m.textarea.Value()
@@ -243,7 +333,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
 	m.height = height
 	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
 	m.textarea.SetHeight(height)
-	m.textarea.SetWidth(width)
 	return nil
 }
 
@@ -314,7 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 func NewEditorCmp(app *app.App) tea.Model {
 	ta := CreateTextArea(nil)
 	return &editorCmp{
-		app:      app,
-		textarea: ta,
+		app:          app,
+		textarea:     ta,
+		history:      []string{},
+		historyIndex: 0,
+		currentMessage: "",
 	}
 }

+ 2 - 0
internal/tui/components/chat/list.go → internal/tui/components/chat/messages.go

@@ -386,6 +386,8 @@ func (m *messagesCmp) help() string {
 			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
 			baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
 			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
+			baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
+			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
 			baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
 			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
 		)

+ 2 - 8
internal/tui/components/chat/sidebar.go

@@ -71,8 +71,7 @@ func (m *sidebarCmp) View() string {
 	return baseStyle.
 		Width(m.width).
 		PaddingLeft(4).
-		PaddingRight(2).
-		Height(m.height - 1).
+		PaddingRight(1).
 		Render(
 			lipgloss.JoinVertical(
 				lipgloss.Top,
@@ -98,14 +97,9 @@ func (m *sidebarCmp) sessionSection() string {
 
 	sessionValue := baseStyle.
 		Foreground(t.Text()).
-		Width(m.width - lipgloss.Width(sessionKey)).
 		Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
 
-	return lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		sessionKey,
-		sessionValue,
-	)
+	return sessionKey + sessionValue
 }
 
 func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {

+ 0 - 2
internal/tui/components/dialog/models.go

@@ -10,7 +10,6 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/llm/models"
-	"github.com/sst/opencode/internal/status"
 	"github.com/sst/opencode/internal/tui/layout"
 	"github.com/sst/opencode/internal/tui/styles"
 	"github.com/sst/opencode/internal/tui/theme"
@@ -127,7 +126,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.switchProvider(1)
 			}
 		case key.Matches(msg, modelKeys.Enter):
-			status.Info(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
 			return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
 		case key.Matches(msg, modelKeys.Escape):
 			return m, util.CmdHandler(CloseModelDialogMsg{})

+ 12 - 1
internal/tui/components/dialog/permission.go

@@ -6,6 +6,7 @@ import (
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/diff"
 	"github.com/sst/opencode/internal/llm/tools"
 	"github.com/sst/opencode/internal/permission"
@@ -13,6 +14,7 @@ import (
 	"github.com/sst/opencode/internal/tui/styles"
 	"github.com/sst/opencode/internal/tui/theme"
 	"github.com/sst/opencode/internal/tui/util"
+	"path/filepath"
 	"strings"
 )
 
@@ -204,10 +206,19 @@ func (p *permissionDialogCmp) renderHeader() string {
 		Render(fmt.Sprintf(": %s", p.permission.ToolName))
 
 	pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
+	
+	// Get the current working directory to display relative path
+	relativePath := p.permission.Path
+	if filepath.IsAbs(relativePath) {
+		if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
+			relativePath = cwd
+		}
+	}
+	
 	pathValue := baseStyle.
 		Foreground(t.Text()).
 		Width(p.width - lipgloss.Width(pathKey)).
-		Render(fmt.Sprintf(": %s", p.permission.Path))
+		Render(fmt.Sprintf(": %s", relativePath))
 
 	headerParts := []string{
 		lipgloss.JoinHorizontal(

+ 178 - 0
internal/tui/components/dialog/tools.go

@@ -0,0 +1,178 @@
+package dialog
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	utilComponents "github.com/sst/opencode/internal/tui/components/util"
+	"github.com/sst/opencode/internal/tui/layout"
+	"github.com/sst/opencode/internal/tui/styles"
+	"github.com/sst/opencode/internal/tui/theme"
+)
+
+const (
+	maxToolsDialogWidth = 60
+	maxVisibleTools     = 15
+)
+
+// ToolsDialog interface for the tools list dialog
+type ToolsDialog interface {
+	tea.Model
+	layout.Bindings
+	SetTools(tools []string)
+}
+
+// ShowToolsDialogMsg is sent to show the tools dialog
+type ShowToolsDialogMsg struct {
+	Show bool
+}
+
+// CloseToolsDialogMsg is sent when the tools dialog is closed
+type CloseToolsDialogMsg struct{}
+
+type toolItem struct {
+	name string
+}
+
+func (t toolItem) Render(selected bool, width int) string {
+	th := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle().
+		Width(width).
+		Background(th.Background())
+	
+	if selected {
+		baseStyle = baseStyle.
+			Background(th.Primary()).
+			Foreground(th.Background()).
+			Bold(true)
+	} else {
+		baseStyle = baseStyle.
+			Foreground(th.Text())
+	}
+	
+	return baseStyle.Render(t.name)
+}
+
+type toolsDialogCmp struct {
+	tools       []toolItem
+	width       int
+	height      int
+	list        utilComponents.SimpleList[toolItem]
+}
+
+type toolsKeyMap struct {
+	Up     key.Binding
+	Down   key.Binding
+	Escape key.Binding
+	J      key.Binding
+	K      key.Binding
+}
+
+var toolsKeys = toolsKeyMap{
+	Up: key.NewBinding(
+		key.WithKeys("up"),
+		key.WithHelp("↑", "previous tool"),
+	),
+	Down: key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("↓", "next tool"),
+	),
+	Escape: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "close"),
+	),
+	J: key.NewBinding(
+		key.WithKeys("j"),
+		key.WithHelp("j", "next tool"),
+	),
+	K: key.NewBinding(
+		key.WithKeys("k"),
+		key.WithHelp("k", "previous tool"),
+	),
+}
+
+func (m *toolsDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *toolsDialogCmp) SetTools(tools []string) {
+	var toolItems []toolItem
+	for _, name := range tools {
+		toolItems = append(toolItems, toolItem{name: name})
+	}
+	
+	m.tools = toolItems
+	m.list.SetItems(toolItems)
+}
+
+func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, toolsKeys.Escape):
+			return m, func() tea.Msg { return CloseToolsDialogMsg{} }
+		// Pass other key messages to the list component
+		default:
+			var cmd tea.Cmd
+			listModel, cmd := m.list.Update(msg)
+			m.list = listModel.(utilComponents.SimpleList[toolItem])
+			return m, cmd
+		}
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+	}
+
+	// For non-key messages
+	var cmd tea.Cmd
+	listModel, cmd := m.list.Update(msg)
+	m.list = listModel.(utilComponents.SimpleList[toolItem])
+	return m, cmd
+}
+
+func (m *toolsDialogCmp) View() string {
+	t := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle().Background(t.Background())
+
+	title := baseStyle.
+		Foreground(t.Primary()).
+		Bold(true).
+		Width(maxToolsDialogWidth).
+		Padding(0, 0, 1).
+		Render("Available Tools")
+
+	// Calculate dialog width based on content
+	dialogWidth := min(maxToolsDialogWidth, m.width/2)
+	m.list.SetMaxWidth(dialogWidth)
+	
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		title,
+		m.list.View(),
+	)
+
+	return baseStyle.Padding(1, 2).
+		Border(lipgloss.RoundedBorder()).
+		BorderBackground(t.Background()).
+		BorderForeground(t.TextMuted()).
+		Background(t.Background()).
+		Width(lipgloss.Width(content) + 4).
+		Render(content)
+}
+
+func (m *toolsDialogCmp) BindingKeys() []key.Binding {
+	return layout.KeyMapToSlice(toolsKeys)
+}
+
+func NewToolsDialogCmp() ToolsDialog {
+	list := utilComponents.NewSimpleList[toolItem](
+		[]toolItem{},
+		maxVisibleTools,
+		"No tools available",
+		true,
+	)
+	
+	return &toolsDialogCmp{
+		list: list,
+	}
+}

+ 3 - 3
internal/tui/components/logs/details.go

@@ -84,7 +84,7 @@ func (i *detailCmp) updateContent() {
 	messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
 	content.WriteString(messageStyle.Render("Message:"))
 	content.WriteString("\n")
-	content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
+	content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.Message))
 	content.WriteString("\n\n")
 
 	// Attributes section
@@ -112,7 +112,7 @@ func (i *detailCmp) updateContent() {
 				valueStyle.Render(value),
 			)
 
-			content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
+			content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(attrLine))
 			content.WriteString("\n")
 		}
 	}
@@ -123,7 +123,7 @@ func (i *detailCmp) updateContent() {
 		content.WriteString("\n")
 		content.WriteString(sessionStyle.Render("Session:"))
 		content.WriteString("\n")
-		content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.SessionID))
+		content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.SessionID))
 	}
 
 	i.viewport.SetContent(content.String())

+ 127 - 0
internal/tui/components/spinner/spinner.go

@@ -0,0 +1,127 @@
+package spinner
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
+type Spinner struct {
+	model  spinner.Model
+	done   chan struct{}
+	prog   *tea.Program
+	ctx    context.Context
+	cancel context.CancelFunc
+}
+
+// spinnerModel is the tea.Model for the spinner
+type spinnerModel struct {
+	spinner  spinner.Model
+	message  string
+	quitting bool
+}
+
+func (m spinnerModel) Init() tea.Cmd {
+	return m.spinner.Tick
+}
+
+func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		m.quitting = true
+		return m, tea.Quit
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		m.spinner, cmd = m.spinner.Update(msg)
+		return m, cmd
+	case quitMsg:
+		m.quitting = true
+		return m, tea.Quit
+	default:
+		return m, nil
+	}
+}
+
+func (m spinnerModel) View() string {
+	if m.quitting {
+		return ""
+	}
+	return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
+}
+
+// quitMsg is sent when we want to quit the spinner
+type quitMsg struct{}
+
+// NewSpinner creates a new spinner with the given message
+func NewSpinner(message string) *Spinner {
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = s.Style.Foreground(s.Style.GetForeground())
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	model := spinnerModel{
+		spinner: s,
+		message: message,
+	}
+
+	prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
+
+	return &Spinner{
+		model:  s,
+		done:   make(chan struct{}),
+		prog:   prog,
+		ctx:    ctx,
+		cancel: cancel,
+	}
+}
+
+// NewThemedSpinner creates a new spinner with the given message and color
+func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = s.Style.Foreground(color)
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	model := spinnerModel{
+		spinner: s,
+		message: message,
+	}
+
+	prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
+
+	return &Spinner{
+		model:  s,
+		done:   make(chan struct{}),
+		prog:   prog,
+		ctx:    ctx,
+		cancel: cancel,
+	}
+}
+
+// Start begins the spinner animation
+func (s *Spinner) Start() {
+	go func() {
+		defer close(s.done)
+		go func() {
+			<-s.ctx.Done()
+			s.prog.Send(quitMsg{})
+		}()
+		_, err := s.prog.Run()
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
+		}
+	}()
+}
+
+// Stop ends the spinner animation
+func (s *Spinner) Stop() {
+	s.cancel()
+	<-s.done
+}

+ 24 - 0
internal/tui/components/spinner/spinner_test.go

@@ -0,0 +1,24 @@
+package spinner
+
+import (
+	"testing"
+	"time"
+)
+
+func TestSpinner(t *testing.T) {
+	t.Parallel()
+
+	// Create a spinner
+	s := NewSpinner("Test spinner")
+	
+	// Start the spinner
+	s.Start()
+	
+	// Wait a bit to let it run
+	time.Sleep(100 * time.Millisecond)
+	
+	// Stop the spinner
+	s.Stop()
+	
+	// If we got here without panicking, the test passes
+}

+ 49 - 0
internal/tui/image/clipboard_unix.go

@@ -0,0 +1,49 @@
+//go:build !windows
+
+package image
+
+import (
+	"bytes"
+	"fmt"
+	"image"
+	"github.com/atotto/clipboard"
+)
+
+func GetImageFromClipboard() ([]byte, string, error) {
+	text, err := clipboard.ReadAll()
+	if err != nil {
+		return nil, "", fmt.Errorf("Error reading clipboard")
+	}
+
+	if text == "" {
+		return nil, "", nil
+	}
+
+	binaryData := []byte(text)
+	imageBytes, err := binaryToImage(binaryData)
+	if err != nil {
+		return nil, text, nil
+	}
+	return imageBytes, "", nil
+
+}
+
+
+
+func binaryToImage(data []byte) ([]byte, error) {
+	reader := bytes.NewReader(data)
+	img, _, err := image.Decode(reader)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to covert bytes to image")
+	}
+
+	return ImageToBytes(img)
+}
+
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}

+ 192 - 0
internal/tui/image/clipboard_windows.go

@@ -0,0 +1,192 @@
+//go:build windows
+
+package image
+
+import (
+	"bytes"
+	"fmt"
+	"image"
+	"image/color"
+	"log/slog"
+	"syscall"
+	"unsafe"
+)
+
+var (
+	user32                     = syscall.NewLazyDLL("user32.dll")
+	kernel32                   = syscall.NewLazyDLL("kernel32.dll")
+	openClipboard              = user32.NewProc("OpenClipboard")
+	closeClipboard             = user32.NewProc("CloseClipboard")
+	getClipboardData           = user32.NewProc("GetClipboardData")
+	isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
+	globalLock                 = kernel32.NewProc("GlobalLock")
+	globalUnlock               = kernel32.NewProc("GlobalUnlock")
+	globalSize                 = kernel32.NewProc("GlobalSize")
+)
+
+const (
+	CF_TEXT        = 1
+	CF_UNICODETEXT = 13
+	CF_DIB         = 8
+)
+
+type BITMAPINFOHEADER struct {
+	BiSize          uint32
+	BiWidth         int32
+	BiHeight        int32
+	BiPlanes        uint16
+	BiBitCount      uint16
+	BiCompression   uint32
+	BiSizeImage     uint32
+	BiXPelsPerMeter int32
+	BiYPelsPerMeter int32
+	BiClrUsed       uint32
+	BiClrImportant  uint32
+}
+
+func GetImageFromClipboard() ([]byte, string, error) {
+	ret, _, _ := openClipboard.Call(0)
+	if ret == 0 {
+		return nil, "", fmt.Errorf("failed to open clipboard")
+	}
+	defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
+		_, _, err := closeClipboard.Call(a...)
+		if err != nil {
+			slog.Error("close clipboard failed")
+			return
+		}
+	}(closeClipboard)
+	isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
+	isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
+
+	if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
+		// Get text from clipboard
+		var formatToUse uintptr = CF_TEXT
+		if isUnicodeTextAvailable != 0 {
+			formatToUse = CF_UNICODETEXT
+		}
+
+		hClipboardText, _, _ := getClipboardData.Call(formatToUse)
+		if hClipboardText != 0 {
+			textPtr, _, _ := globalLock.Call(hClipboardText)
+			if textPtr != 0 {
+				defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
+					_, _, err := globalUnlock.Call(a...)
+					if err != nil {
+						slog.Error("Global unlock failed")
+						return
+					}
+				}(globalUnlock, hClipboardText)
+
+				// Get clipboard text
+				var clipboardText string
+				if formatToUse == CF_UNICODETEXT {
+					// Convert wide string to Go string
+					clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
+				} else {
+					// Get size of ANSI text
+					size, _, _ := globalSize.Call(hClipboardText)
+					if size > 0 {
+						// Convert ANSI string to Go string
+						textBytes := make([]byte, size)
+						copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
+						clipboardText = bytesToString(textBytes)
+					}
+				}
+
+				// Check if the text is not empty
+				if clipboardText != "" {
+					return nil, clipboardText, nil
+				}
+			}
+		}
+	}
+	hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
+	if hClipboardData == 0 {
+		return nil, "", fmt.Errorf("failed to get clipboard data")
+	}
+
+	dataPtr, _, _ := globalLock.Call(hClipboardData)
+	if dataPtr == 0 {
+		return nil, "", fmt.Errorf("failed to lock clipboard data")
+	}
+	defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
+		_, _, err := globalUnlock.Call(a...)
+		if err != nil {
+			slog.Error("Global unlock failed")
+			return
+		}
+	}(globalUnlock, hClipboardData)
+
+	bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
+
+	width := int(bmiHeader.BiWidth)
+	height := int(bmiHeader.BiHeight)
+	if height < 0 {
+		height = -height
+	}
+	bitsPerPixel := int(bmiHeader.BiBitCount)
+
+	img := image.NewRGBA(image.Rect(0, 0, width, height))
+
+	var bitsOffset uintptr
+	if bitsPerPixel <= 8 {
+		numColors := uint32(1) << bitsPerPixel
+		if bmiHeader.BiClrUsed > 0 {
+			numColors = bmiHeader.BiClrUsed
+		}
+		bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
+	} else {
+		bitsOffset = unsafe.Sizeof(*bmiHeader)
+	}
+
+	for y := range height {
+		for x := range width {
+
+			srcY := height - y - 1
+			if bmiHeader.BiHeight < 0 {
+				srcY = y
+			}
+
+			var pixelPointer unsafe.Pointer
+			var r, g, b, a uint8
+
+			switch bitsPerPixel {
+			case 24:
+				stride := (width*3 + 3) &^ 3
+				pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
+				b = *(*byte)(pixelPointer)
+				g = *(*byte)(unsafe.Add(pixelPointer, 1))
+				r = *(*byte)(unsafe.Add(pixelPointer, 2))
+				a = 255
+			case 32:
+				pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
+				b = *(*byte)(pixelPointer)
+				g = *(*byte)(unsafe.Add(pixelPointer, 1))
+				r = *(*byte)(unsafe.Add(pixelPointer, 2))
+				a = *(*byte)(unsafe.Add(pixelPointer, 3))
+				if a == 0 {
+					a = 255
+				}
+			default:
+				return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
+			}
+
+			img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
+		}
+	}
+
+	imageBytes, err := ImageToBytes(img)
+	if err != nil {
+		return nil, "", err
+	}
+	return imageBytes, "", nil
+}
+
+func bytesToString(b []byte) string {
+	i := bytes.IndexByte(b, 0)
+	if i == -1 {
+		return string(b)
+	}
+	return string(b[:i])
+}

+ 12 - 0
internal/tui/image/images.go

@@ -1,8 +1,10 @@
 package image
 
 import (
+	"bytes"
 	"fmt"
 	"image"
+	"image/png"
 	"os"
 	"strings"
 
@@ -71,3 +73,13 @@ func ImagePreview(width int, filename string) (string, error) {
 
 	return imageString, nil
 }
+
+func ImageToBytes(image image.Image) ([]byte, error) {
+	buf := new(bytes.Buffer)
+    err := png.Encode(buf, image)
+    if err != nil {
+        return nil, err
+    }
+    
+    return buf.Bytes(), nil
+}

+ 4 - 7
internal/tui/layout/container.go

@@ -11,16 +11,16 @@ type Container interface {
 	tea.Model
 	Sizeable
 	Bindings
-	Focus() // Add focus method
-	Blur()  // Add blur method
+	Focus()
+	Blur()
 }
+
 type container struct {
 	width  int
 	height int
 
 	content tea.Model
 
-	// Style options
 	paddingTop    int
 	paddingRight  int
 	paddingBottom int
@@ -32,7 +32,7 @@ type container struct {
 	borderLeft   bool
 	borderStyle  lipgloss.Border
 
-	focused bool // Track focus state
+	focused bool
 }
 
 func (c *container) Init() tea.Cmd {
@@ -152,16 +152,13 @@ func (c *container) Blur() {
 type ContainerOption func(*container)
 
 func NewContainer(content tea.Model, options ...ContainerOption) Container {
-
 	c := &container{
 		content:     content,
 		borderStyle: lipgloss.NormalBorder(),
 	}
-
 	for _, option := range options {
 		option(c)
 	}
-
 	return c
 }
 

+ 48 - 0
internal/tui/theme/manager.go

@@ -202,6 +202,54 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
 			theme.DiffAddedLineNumberBgColor = adaptiveColor
 		case "diffremovedlinenumberbg":
 			theme.DiffRemovedLineNumberBgColor = adaptiveColor
+		case "syntaxcomment":
+			theme.SyntaxCommentColor = adaptiveColor
+		case "syntaxkeyword":
+			theme.SyntaxKeywordColor = adaptiveColor
+		case "syntaxfunction":
+			theme.SyntaxFunctionColor = adaptiveColor
+		case "syntaxvariable":
+			theme.SyntaxVariableColor = adaptiveColor
+		case "syntaxstring":
+			theme.SyntaxStringColor = adaptiveColor
+		case "syntaxnumber":
+			theme.SyntaxNumberColor = adaptiveColor
+		case "syntaxtype":
+			theme.SyntaxTypeColor = adaptiveColor
+		case "syntaxoperator":
+			theme.SyntaxOperatorColor = adaptiveColor
+		case "syntaxpunctuation":
+			theme.SyntaxPunctuationColor = adaptiveColor
+		case "markdowntext":
+			theme.MarkdownTextColor = adaptiveColor
+		case "markdownheading":
+			theme.MarkdownHeadingColor = adaptiveColor
+		case "markdownlink":
+			theme.MarkdownLinkColor = adaptiveColor
+		case "markdownlinktext":
+			theme.MarkdownLinkTextColor = adaptiveColor
+		case "markdowncode":
+			theme.MarkdownCodeColor = adaptiveColor
+		case "markdownblockquote":
+			theme.MarkdownBlockQuoteColor = adaptiveColor
+		case "markdownemph":
+			theme.MarkdownEmphColor = adaptiveColor
+		case "markdownstrong":
+			theme.MarkdownStrongColor = adaptiveColor
+		case "markdownhorizontalrule":
+			theme.MarkdownHorizontalRuleColor = adaptiveColor
+		case "markdownlistitem":
+			theme.MarkdownListItemColor = adaptiveColor
+		case "markdownlistitemenum":
+			theme.MarkdownListEnumerationColor = adaptiveColor
+		case "markdownimage":
+			theme.MarkdownImageColor = adaptiveColor
+		case "markdownimagetext":
+			theme.MarkdownImageTextColor = adaptiveColor
+		case "markdowncodeblock":
+			theme.MarkdownCodeBlockColor = adaptiveColor
+		case "markdownlistenumeration":
+			theme.MarkdownListEnumerationColor = adaptiveColor
 		default:
 			slog.Warn("Unknown color key in custom theme", "key", key)
 		}

+ 27 - 2
internal/tui/theme/theme.go

@@ -235,7 +235,19 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
 		}, nil
 	}
 
-	// Case 2: Map with dark and light keys
+	// Case 2: Int value between 0 and 255
+	if numericVal, ok := value.(float64); ok {
+		intVal := int(numericVal)
+		if intVal < 0 || intVal > 255 {
+			return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid int color value (must be between 0 and 255): %d", intVal)
+		}
+		return lipgloss.AdaptiveColor{
+			Dark:  fmt.Sprintf("%d", intVal),
+			Light: fmt.Sprintf("%d", intVal),
+		}, nil
+	}
+
+	// Case 3: Map with dark and light keys
 	if colorMap, ok := value.(map[string]any); ok {
 		darkVal, darkOk := colorMap["dark"]
 		lightVal, lightOk := colorMap["light"]
@@ -248,7 +260,20 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
 		lightHex, lightIsString := lightVal.(string)
 
 		if !darkIsString || !lightIsString {
-			return lipgloss.AdaptiveColor{}, fmt.Errorf("color values must be strings")
+			darkVal, darkIsNumber := darkVal.(float64)
+			lightVal, lightIsNumber := lightVal.(float64)
+
+			if !darkIsNumber || !lightIsNumber {
+				return lipgloss.AdaptiveColor{}, fmt.Errorf("color map values must be strings or ints")
+			}
+
+			darkInt := int(darkVal)
+			lightInt := int(lightVal)
+
+			return lipgloss.AdaptiveColor{
+				Dark:  fmt.Sprintf("%d", darkInt),
+				Light: fmt.Sprintf("%d", lightInt),
+			}, nil
 		}
 
 		if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {

+ 127 - 0
internal/tui/tui.go

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/config"
+	"github.com/sst/opencode/internal/llm/agent"
 	"github.com/sst/opencode/internal/logging"
 	"github.com/sst/opencode/internal/message"
 	"github.com/sst/opencode/internal/permission"
@@ -38,6 +39,7 @@ type keyMap struct {
 	Filepicker    key.Binding
 	Models        key.Binding
 	SwitchTheme   key.Binding
+	Tools         key.Binding
 }
 
 const (
@@ -81,6 +83,11 @@ var keys = keyMap{
 		key.WithKeys("ctrl+t"),
 		key.WithHelp("ctrl+t", "switch theme"),
 	),
+	
+	Tools: key.NewBinding(
+		key.WithKeys("f9"),
+		key.WithHelp("f9", "show available tools"),
+	),
 }
 
 var helpEsc = key.NewBinding(
@@ -137,6 +144,9 @@ type appModel struct {
 
 	showMultiArgumentsDialog bool
 	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
+	
+	showToolsDialog bool
+	toolsDialog     dialog.ToolsDialog
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
 	cmds = append(cmds, cmd)
 	cmd = a.themeDialog.Init()
 	cmds = append(cmds, cmd)
+	cmd = a.toolsDialog.Init()
+	cmds = append(cmds, cmd)
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case dialog.CloseThemeDialogMsg:
 		a.showThemeDialog = false
 		return a, nil
+		
+	case dialog.CloseToolsDialogMsg:
+		a.showToolsDialog = false
+		return a, nil
+		
+	case dialog.ShowToolsDialogMsg:
+		a.showToolsDialog = msg.Show
+		return a, nil
 
 	case dialog.ThemeChangedMsg:
 		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -397,6 +417,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if a.showFilepicker {
 				a.showFilepicker = false
 				a.filepicker.ToggleFilepicker(a.showFilepicker)
+				a.app.SetFilepickerOpen(a.showFilepicker)
 			}
 			if a.showModelDialog {
 				a.showModelDialog = false
@@ -404,9 +425,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if a.showMultiArgumentsDialog {
 				a.showMultiArgumentsDialog = false
 			}
+			if a.showToolsDialog {
+				a.showToolsDialog = false
+			}
 			return a, nil
 		case key.Matches(msg, keys.SwitchSession):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
+				// Close other dialogs
+				a.showToolsDialog = false
+				a.showThemeDialog = false
+				a.showModelDialog = false
+				a.showFilepicker = false
+				
 				// Load sessions and show the dialog
 				sessions, err := a.app.Sessions.List(context.Background())
 				if err != nil {
@@ -424,6 +454,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, nil
 		case key.Matches(msg, keys.Commands):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
+				// Close other dialogs
+				a.showToolsDialog = false
+				a.showModelDialog = false
+				
 				// Show commands dialog
 				if len(a.commands) == 0 {
 					status.Warn("No commands available")
@@ -440,22 +474,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 			}
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+				// Close other dialogs
+				a.showToolsDialog = false
+				a.showThemeDialog = false
+				a.showFilepicker = false
+				
 				a.showModelDialog = true
 				return a, nil
 			}
 			return a, nil
 		case key.Matches(msg, keys.SwitchTheme):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+				// Close other dialogs
+				a.showToolsDialog = false
+				a.showModelDialog = false
+				a.showFilepicker = false
+				
 				a.showThemeDialog = true
 				return a, a.themeDialog.Init()
 			}
 			return a, nil
+		case key.Matches(msg, keys.Tools):
+			// Check if any other dialog is open
+			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && 
+			   !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog && 
+			   !a.showFilepicker && !a.showModelDialog && !a.showInitDialog && 
+			   !a.showMultiArgumentsDialog {
+				// Toggle tools dialog
+				a.showToolsDialog = !a.showToolsDialog
+				if a.showToolsDialog {
+					// Get tool names dynamically
+					toolNames := getAvailableToolNames(a.app)
+					a.toolsDialog.SetTools(toolNames)
+				}
+				return a, nil
+			}
+			return a, nil
 		case key.Matches(msg, returnKey) || key.Matches(msg):
 			if msg.String() == quitKey {
 				if a.currentPage == page.LogsPage {
 					return a, a.moveToPage(page.ChatPage)
 				}
 			} else if !a.filepicker.IsCWDFocused() {
+				if a.showToolsDialog {
+					a.showToolsDialog = false
+					return a, nil
+				}
 				if a.showQuit {
 					a.showQuit = !a.showQuit
 					return a, nil
@@ -476,6 +540,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				if a.showFilepicker {
 					a.showFilepicker = false
 					a.filepicker.ToggleFilepicker(a.showFilepicker)
+					a.app.SetFilepickerOpen(a.showFilepicker)
 					return a, nil
 				}
 				if a.currentPage == page.LogsPage {
@@ -490,6 +555,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 			}
 			a.showHelp = !a.showHelp
+			
+			// Close other dialogs if opening help
+			if a.showHelp {
+				a.showToolsDialog = false
+			}
 			return a, nil
 		case key.Matches(msg, helpEsc):
 			if a.app.PrimaryAgent.IsBusy() {
@@ -500,8 +570,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 			}
 		case key.Matches(msg, keys.Filepicker):
+			// Toggle filepicker
 			a.showFilepicker = !a.showFilepicker
 			a.filepicker.ToggleFilepicker(a.showFilepicker)
+			a.app.SetFilepickerOpen(a.showFilepicker)
+			
+			// Close other dialogs if opening filepicker
+			if a.showFilepicker {
+				a.showToolsDialog = false
+				a.showThemeDialog = false
+				a.showModelDialog = false
+				a.showCommandDialog = false
+				a.showSessionDialog = false
+			}
 			return a, nil
 		}
 
@@ -600,6 +681,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, tea.Batch(cmds...)
 		}
 	}
+	
+	if a.showToolsDialog {
+		d, toolsCmd := a.toolsDialog.Update(msg)
+		a.toolsDialog = d.(dialog.ToolsDialog)
+		cmds = append(cmds, toolsCmd)
+		// Only block key messages send all other messages down
+		if _, ok := msg.(tea.KeyMsg); ok {
+			return a, tea.Batch(cmds...)
+		}
+	}
 
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
@@ -615,6 +706,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
 	a.commands = append(a.commands, cmd)
 }
 
+// getAvailableToolNames returns a list of all available tool names
+func getAvailableToolNames(app *app.App) []string {
+	// Get primary agent tools (which already include MCP tools)
+	allTools := agent.PrimaryAgentTools(
+		app.Permissions,
+		app.Sessions,
+		app.Messages,
+		app.History,
+		app.LSPClients,
+	)
+	
+	// Extract tool names
+	var toolNames []string
+	for _, tool := range allTools {
+		toolNames = append(toolNames, tool.Info().Name)
+	}
+	
+	return toolNames
+}
+
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	// Allow navigating to logs page even when agent is busy
 	if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
@@ -820,6 +931,21 @@ func (a appModel) View() string {
 			true,
 		)
 	}
+	
+	if a.showToolsDialog {
+		overlay := a.toolsDialog.View()
+		row := lipgloss.Height(appView) / 2
+		row -= lipgloss.Height(overlay) / 2
+		col := lipgloss.Width(appView) / 2
+		col -= lipgloss.Width(overlay) / 2
+		appView = layout.PlaceOverlay(
+			col,
+			row,
+			overlay,
+			appView,
+			true,
+		)
+	}
 
 	return appView
 }
@@ -838,6 +964,7 @@ func New(app *app.App) tea.Model {
 		permissions:   dialog.NewPermissionDialogCmp(),
 		initDialog:    dialog.NewInitDialogCmp(),
 		themeDialog:   dialog.NewThemeDialogCmp(),
+		toolsDialog:   dialog.NewToolsDialogCmp(),
 		app:           app,
 		commands:      []dialog.Command{},
 		pages: map[page.PageID]tea.Model{

+ 114 - 91
opencode-schema.json

@@ -12,63 +12,74 @@
         "model": {
           "description": "Model ID for the agent",
           "enum": [
-            "azure.o1-mini",
-            "openrouter.gemini-2.5-flash",
-            "claude-3-haiku",
-            "o1-mini",
-            "qwen-qwq",
-            "llama-3.3-70b-versatile",
-            "openrouter.claude-3.5-sonnet",
-            "o3-mini",
-            "o4-mini",
             "gpt-4.1",
-            "azure.o3-mini",
-            "openrouter.gpt-4.1-nano",
-            "openrouter.gpt-4o",
-            "gemini-2.5",
-            "azure.gpt-4o",
-            "azure.gpt-4o-mini",
-            "claude-3.7-sonnet",
-            "azure.gpt-4.1-nano",
-            "openrouter.o1",
-            "openrouter.claude-3-haiku",
-            "bedrock.claude-3.7-sonnet",
-            "gemini-2.5-flash",
-            "azure.o3",
-            "openrouter.gemini-2.5",
             "openrouter.o3",
-            "openrouter.o3-mini",
-            "openrouter.gpt-4.1-mini",
-            "openrouter.gpt-4.5-preview",
-            "openrouter.gpt-4o-mini",
-            "gpt-4.1-mini",
+            "openrouter.gpt-4.1",
             "meta-llama/llama-4-scout-17b-16e-instruct",
-            "openrouter.o1-mini",
+            "openrouter.gpt-4o",
+            "o1-pro",
+            "claude-3-haiku",
+            "o1",
+            "gemini-2.5-flash",
+            "vertexai.gemini-2.5-flash",
+            "claude-3.5-haiku",
+            "gpt-4o-mini",
+            "o3-mini",
             "gpt-4.5-preview",
+            "azure.gpt-4o",
+            "azure.o4-mini",
+            "openrouter.claude-3.5-sonnet",
+            "gpt-4o",
             "o3",
+            "gpt-4.1-mini",
+            "llama-3.3-70b-versatile",
+            "azure.gpt-4o-mini",
+            "gpt-4.1-nano",
+            "o4-mini",
+            "qwen-qwq",
             "openrouter.claude-3.5-haiku",
-            "claude-3-opus",
-            "o1-pro",
-            "gemini-2.0-flash",
-            "azure.o4-mini",
-            "openrouter.o4-mini",
+            "openrouter.qwen-3-14b",
+            "vertexai.gemini-2.5",
+            "gemini-2.5",
+            "azure.gpt-4.1-nano",
+            "openrouter.o1-mini",
+            "openrouter.qwen-3-30b",
+            "claude-3.7-sonnet",
             "claude-3.5-sonnet",
+            "gemini-2.0-flash",
             "meta-llama/llama-4-maverick-17b-128e-instruct",
-            "azure.o1",
-            "openrouter.gpt-4.1",
+            "openrouter.o3-mini",
+            "openrouter.o4-mini",
+            "openrouter.gpt-4.1-mini",
+            "openrouter.o1",
+            "o1-mini",
+            "azure.gpt-4.1-mini",
             "openrouter.o1-pro",
-            "gpt-4.1-nano",
-            "azure.gpt-4.5-preview",
+            "grok-3-beta",
+            "grok-3-mini-fast-beta",
+            "openrouter.claude-3.7-sonnet",
             "openrouter.claude-3-opus",
-            "gpt-4o-mini",
-            "o1",
+            "openrouter.qwen-3-235b",
+            "openrouter.gpt-4.1-nano",
+            "bedrock.claude-3.7-sonnet",
+            "openrouter.qwen-3-8b",
+            "claude-3-opus",
+            "azure.o1-mini",
             "deepseek-r1-distill-llama-70b",
+            "gemini-2.0-flash-lite",
+            "openrouter.qwen-3-32b",
+            "openrouter.gpt-4.5-preview",
+            "grok-3-mini-beta",
+            "grok-3-fast-beta",
+            "azure.o3-mini",
+            "openrouter.claude-3-haiku",
             "azure.gpt-4.1",
-            "gpt-4o",
-            "azure.gpt-4.1-mini",
-            "openrouter.claude-3.7-sonnet",
-            "claude-3.5-haiku",
-            "gemini-2.0-flash-lite"
+            "azure.o1",
+            "azure.o3",
+            "azure.gpt-4.5-preview",
+            "openrouter.gemini-2.5-flash",
+            "openrouter.gpt-4o-mini",
+            "openrouter.gemini-2.5"
           ],
           "type": "string"
         },
@@ -102,63 +113,74 @@
           "model": {
             "description": "Model ID for the agent",
             "enum": [
-              "azure.o1-mini",
-              "openrouter.gemini-2.5-flash",
-              "claude-3-haiku",
-              "o1-mini",
-              "qwen-qwq",
-              "llama-3.3-70b-versatile",
-              "openrouter.claude-3.5-sonnet",
-              "o3-mini",
-              "o4-mini",
               "gpt-4.1",
-              "azure.o3-mini",
-              "openrouter.gpt-4.1-nano",
-              "openrouter.gpt-4o",
-              "gemini-2.5",
-              "azure.gpt-4o",
-              "azure.gpt-4o-mini",
-              "claude-3.7-sonnet",
-              "azure.gpt-4.1-nano",
-              "openrouter.o1",
-              "openrouter.claude-3-haiku",
-              "bedrock.claude-3.7-sonnet",
-              "gemini-2.5-flash",
-              "azure.o3",
-              "openrouter.gemini-2.5",
               "openrouter.o3",
-              "openrouter.o3-mini",
-              "openrouter.gpt-4.1-mini",
-              "openrouter.gpt-4.5-preview",
-              "openrouter.gpt-4o-mini",
-              "gpt-4.1-mini",
+              "openrouter.gpt-4.1",
               "meta-llama/llama-4-scout-17b-16e-instruct",
-              "openrouter.o1-mini",
+              "openrouter.gpt-4o",
+              "o1-pro",
+              "claude-3-haiku",
+              "o1",
+              "gemini-2.5-flash",
+              "vertexai.gemini-2.5-flash",
+              "claude-3.5-haiku",
+              "gpt-4o-mini",
+              "o3-mini",
               "gpt-4.5-preview",
+              "azure.gpt-4o",
+              "azure.o4-mini",
+              "openrouter.claude-3.5-sonnet",
+              "gpt-4o",
               "o3",
+              "gpt-4.1-mini",
+              "llama-3.3-70b-versatile",
+              "azure.gpt-4o-mini",
+              "gpt-4.1-nano",
+              "o4-mini",
+              "qwen-qwq",
               "openrouter.claude-3.5-haiku",
-              "claude-3-opus",
-              "o1-pro",
-              "gemini-2.0-flash",
-              "azure.o4-mini",
-              "openrouter.o4-mini",
+              "openrouter.qwen-3-14b",
+              "vertexai.gemini-2.5",
+              "gemini-2.5",
+              "azure.gpt-4.1-nano",
+              "openrouter.o1-mini",
+              "openrouter.qwen-3-30b",
+              "claude-3.7-sonnet",
               "claude-3.5-sonnet",
+              "gemini-2.0-flash",
               "meta-llama/llama-4-maverick-17b-128e-instruct",
-              "azure.o1",
-              "openrouter.gpt-4.1",
+              "openrouter.o3-mini",
+              "openrouter.o4-mini",
+              "openrouter.gpt-4.1-mini",
+              "openrouter.o1",
+              "o1-mini",
+              "azure.gpt-4.1-mini",
               "openrouter.o1-pro",
-              "gpt-4.1-nano",
-              "azure.gpt-4.5-preview",
+              "grok-3-beta",
+              "grok-3-mini-fast-beta",
+              "openrouter.claude-3.7-sonnet",
               "openrouter.claude-3-opus",
-              "gpt-4o-mini",
-              "o1",
+              "openrouter.qwen-3-235b",
+              "openrouter.gpt-4.1-nano",
+              "bedrock.claude-3.7-sonnet",
+              "openrouter.qwen-3-8b",
+              "claude-3-opus",
+              "azure.o1-mini",
               "deepseek-r1-distill-llama-70b",
+              "gemini-2.0-flash-lite",
+              "openrouter.qwen-3-32b",
+              "openrouter.gpt-4.5-preview",
+              "grok-3-mini-beta",
+              "grok-3-fast-beta",
+              "azure.o3-mini",
+              "openrouter.claude-3-haiku",
               "azure.gpt-4.1",
-              "gpt-4o",
-              "azure.gpt-4.1-mini",
-              "openrouter.claude-3.7-sonnet",
-              "claude-3.5-haiku",
-              "gemini-2.0-flash-lite"
+              "azure.o1",
+              "azure.o3",
+              "azure.gpt-4.5-preview",
+              "openrouter.gemini-2.5-flash",
+              "openrouter.gpt-4o-mini",
+              "openrouter.gemini-2.5"
             ],
             "type": "string"
           },
@@ -341,7 +363,8 @@
               "groq",
               "openrouter",
               "bedrock",
-              "azure"
+              "azure",
+              "vertexai"
             ],
             "type": "string"
           }