From be5ef100608697f2d71b0c48206ca72c4045e845 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Thu, 5 Jun 2025 22:20:58 +0700 Subject: [PATCH 01/11] feat(models): update gemini-2.5-flash to preview-05-20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with opencode Co-Authored-By: opencode --- internal/llm/models/gemini.go | 2 +- internal/llm/models/vertexai.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/llm/models/gemini.go b/internal/llm/models/gemini.go index 794ec3f0a0..a19816190f 100644 --- a/internal/llm/models/gemini.go +++ b/internal/llm/models/gemini.go @@ -15,7 +15,7 @@ var GeminiModels = map[ModelID]Model{ ID: Gemini25Flash, Name: "Gemini 2.5 Flash", Provider: ProviderGemini, - APIModel: "gemini-2.5-flash-preview-04-17", + APIModel: "gemini-2.5-flash-preview-05-20", CostPer1MIn: 0.15, CostPer1MInCached: 0, CostPer1MOutCached: 0, diff --git a/internal/llm/models/vertexai.go b/internal/llm/models/vertexai.go index d71dfc0bed..7ef903b4d8 100644 --- a/internal/llm/models/vertexai.go +++ b/internal/llm/models/vertexai.go @@ -13,7 +13,7 @@ var VertexAIGeminiModels = map[ModelID]Model{ ID: VertexAIGemini25Flash, Name: "VertexAI: Gemini 2.5 Flash", Provider: ProviderVertexAI, - APIModel: "gemini-2.5-flash-preview-04-17", + APIModel: "gemini-2.5-flash-preview-05-20", CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn, CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached, CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut, From a6a7267d411c31509390ec2a6c6f7b1e5aa2d7e7 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Thu, 5 Jun 2025 22:37:04 +0700 Subject: [PATCH 02/11] fix(models): update gemini cached token costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with opencode Co-Authored-By: opencode --- internal/llm/models/gemini.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/llm/models/gemini.go b/internal/llm/models/gemini.go index a19816190f..2e28673994 100644 --- a/internal/llm/models/gemini.go +++ b/internal/llm/models/gemini.go @@ -17,7 +17,7 @@ var GeminiModels = map[ModelID]Model{ Provider: ProviderGemini, APIModel: "gemini-2.5-flash-preview-05-20", CostPer1MIn: 0.15, - CostPer1MInCached: 0, + CostPer1MInCached: 0.0375, CostPer1MOutCached: 0, CostPer1MOut: 0.60, ContextWindow: 1000000, @@ -30,7 +30,7 @@ var GeminiModels = map[ModelID]Model{ Provider: ProviderGemini, APIModel: "gemini-2.5-pro-preview-05-06", CostPer1MIn: 1.25, - CostPer1MInCached: 0, + CostPer1MInCached: 0.31, CostPer1MOutCached: 0, CostPer1MOut: 10, ContextWindow: 1000000, From 4f107f74b0873441e78a408f9a2f633a0433fa66 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Thu, 5 Jun 2025 23:26:46 +0700 Subject: [PATCH 03/11] feat: Add global always-allow permission setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new global configuration option `alwaysAllowPermissions` and a corresponding CLI flag `--always-allow-permissions` (shorthand `-A`). When this setting is enabled, all permission prompts during a session are bypassed, and operations are automatically allowed. This provides a more streamlined experience for users who trust all operations performed by the tool. The configuration is persisted across sessions. 🤖 Generated with opencode Co-Authored-By: opencode --- cmd/root.go | 62 ++++++++++++++++++++----------- internal/config/config.go | 31 ++++++++++++---- internal/permission/permission.go | 6 +++ 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3a58cec4ed..8ff297dd90 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,8 @@ package cmd import ( "context" - "fmt" "os" + "fmt" "sync" "time" @@ -21,8 +21,15 @@ import ( "github.com/spf13/cobra" ) +var ( + // These are populated by cobra during flag parsing + debug bool + cwd string + prompt string + alwaysAllowPermissions bool +) var rootCmd = &cobra.Command{ - Use: "opencode", + Use: "opencode", Short: "Terminal-based AI assistant for software development", Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks. It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration @@ -46,6 +53,24 @@ to assist developers in writing, debugging, and understanding code directly from # Run a single non-interactive prompt with JSON output format opencode -p "Explain the use of context in Go" -f json `, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Ensure CWD is determined correctly before loading config + if cwd == "" { + c, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %v", err) + } + cwd = c + } + _, err := config.Load(cwd, debug) + if err != nil { + return fmt.Errorf("failed to load initial configuration: %w", err) + } + if cmd.Flags().Changed("always-allow-permissions") { + return config.UpdateAlwaysAllowPermissions(alwaysAllowPermissions) + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { // If the help flag is set, show the help message if cmd.Flag("help").Changed { @@ -58,9 +83,11 @@ to assist developers in writing, debugging, and understanding code directly from } // Load the config - debug, _ := cmd.Flags().GetBool("debug") - cwd, _ := cmd.Flags().GetString("cwd") - prompt, _ := cmd.Flags().GetString("prompt") + // Config is already loaded by PersistentPreRunE. We can get it directly. + cfg := config.Get() + if cfg == nil { // Should not happen if PersistentPreRunE ran successfully + return fmt.Errorf("configuration not loaded") + } outputFormat, _ := cmd.Flags().GetString("output-format") quiet, _ := cmd.Flags().GetBool("quiet") @@ -69,22 +96,12 @@ to assist developers in writing, debugging, and understanding code directly from return fmt.Errorf("invalid format option: %s\n%s", outputFormat, format.GetHelpText()) } - if cwd != "" { - err := os.Chdir(cwd) - if err != nil { - return fmt.Errorf("failed to change directory: %v", err) - } - } - if cwd == "" { - c, err := os.Getwd() + // CWD logic is now handled in PersistentPreRunE or by direct use of cwd + if cwd != cfg.WorkingDir { // Ensure current directory matches config if changed by Chdir + err := os.Chdir(cfg.WorkingDir) if err != nil { - return fmt.Errorf("failed to get current working directory: %v", err) + return fmt.Errorf("failed to change directory to config working dir: %v", err) } - cwd = c - } - _, err := config.Load(cwd, debug) - if err != nil { - return err } // Connect DB, this will also run migrations @@ -291,9 +308,10 @@ func Execute() { func init() { rootCmd.Flags().BoolP("help", "h", false, "Help") 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", "", "Prompt to run in non-interactive mode") + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Debug") + rootCmd.PersistentFlags().StringVarP(&cwd, "cwd", "c", "", "Current working directory") + rootCmd.PersistentFlags().StringVarP(&prompt, "prompt", "p", "", "Prompt to run in non-interactive mode") + rootCmd.PersistentFlags().BoolVarP(&alwaysAllowPermissions, "always-allow-permissions", "A", false, "Globally allow all permissions without prompting for the current and future sessions") // Add format flag with validation logic rootCmd.Flags().StringP("output-format", "f", format.Text.String(), diff --git a/internal/config/config.go b/internal/config/config.go index 5a0905bba2..8635e6264d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,16 +83,17 @@ type ShellConfig struct { type Config struct { Data Data `json:"data"` WorkingDir string `json:"wd,omitempty"` - MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` + MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` LSP map[string]LSPConfig `json:"lsp,omitempty"` Agents map[AgentName]Agent `json:"agents,omitempty"` Debug bool `json:"debug,omitempty"` DebugLSP bool `json:"debugLSP,omitempty"` ContextPaths []string `json:"contextPaths,omitempty"` - TUI TUIConfig `json:"tui"` - Shell ShellConfig `json:"shell,omitempty"` - AutoCompact bool `json:"autoCompact,omitempty"` + TUI TUIConfig `json:"tui"` + Shell ShellConfig `json:"shell,omitempty"` + AutoCompact bool `json:"autoCompact,omitempty"` + AlwaysAllowPermissions bool `json:"alwaysAllowPermissions,omitempty"` // New field } // Application constants @@ -221,8 +222,9 @@ func configureViper() { func setDefaults(debug bool) { viper.SetDefault("data.directory", defaultDataDirectory) viper.SetDefault("contextPaths", defaultContextPaths) - viper.SetDefault("tui.theme", "opencode") + viper.SetDefault("tui.theme", "opencode") viper.SetDefault("autoCompact", true) + viper.SetDefault("alwaysAllowPermissions", false) // New default // Set default shell from environment or fallback to /bin/bash shellPath := os.Getenv("SHELL") @@ -874,7 +876,22 @@ func UpdateTheme(themeName string) error { cfg.TUI.Theme = themeName // Update the file config - return updateCfgFile(func(config *Config) { - config.TUI.Theme = themeName + return updateCfgFile(func(userCfg *Config) { + userCfg.TUI.Theme = themeName + }) +} + +// UpdateAlwaysAllowPermissions updates the alwaysAllowPermissions setting in the configuration and writes it to the config file. +func UpdateAlwaysAllowPermissions(value bool) error { + if cfg == nil { + return fmt.Errorf("config not loaded") + } + + // Update the in-memory config + cfg.AlwaysAllowPermissions = value + + // Update the file config + return updateCfgFile(func(userCfg *Config) { // Match existing pattern in updateCfgFile + userCfg.AlwaysAllowPermissions = value }) } diff --git a/internal/permission/permission.go b/internal/permission/permission.go index d6fdea6644..d447fb61b8 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -72,6 +72,12 @@ func (s *permissionService) Deny(permission PermissionRequest) { } func (s *permissionService) Request(opts CreatePermissionRequest) bool { + // Check global "always allow" setting first + globalCfg := config.Get() + if globalCfg != nil && globalCfg.AlwaysAllowPermissions { + return true + } + if slices.Contains(s.autoApproveSessions, opts.SessionID) { return true } From 5e799358be0b43815ae9e44a4f812b2dbf8bba3e Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Fri, 6 Jun 2025 01:56:56 +0700 Subject: [PATCH 04/11] feat(llm): update Gemini Pro model to preview-06-05 --- internal/llm/models/gemini.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/models/gemini.go b/internal/llm/models/gemini.go index 2e28673994..fed41da88f 100644 --- a/internal/llm/models/gemini.go +++ b/internal/llm/models/gemini.go @@ -28,7 +28,7 @@ var GeminiModels = map[ModelID]Model{ ID: Gemini25, Name: "Gemini 2.5 Pro", Provider: ProviderGemini, - APIModel: "gemini-2.5-pro-preview-05-06", + APIModel: "gemini-2.5-pro-preview-06-05", CostPer1MIn: 1.25, CostPer1MInCached: 0.31, CostPer1MOutCached: 0, From bd30caad00442cf8517fdfa03bb54f54a3255712 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Fri, 6 Jun 2025 09:56:45 +0700 Subject: [PATCH 05/11] feat(config): make always-allow-permissions a manual setting The --always-allow-permissions flag no longer automatically updates the user's configuration file. This change ensures that the setting is manually configured by the user, and only affects the current session unless explicitly set in the config. --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 8ff297dd90..dbf781c914 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,7 +67,7 @@ to assist developers in writing, debugging, and understanding code directly from return fmt.Errorf("failed to load initial configuration: %w", err) } if cmd.Flags().Changed("always-allow-permissions") { - return config.UpdateAlwaysAllowPermissions(alwaysAllowPermissions) + config.Get().AlwaysAllowPermissions = alwaysAllowPermissions } return nil }, From 0528833cfa9e30df7fc4ec3c131a4c5d064f0f20 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 7 Jun 2025 12:14:34 +0700 Subject: [PATCH 06/11] feat: Add ability to hide and show the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces the ability for users to hide and show the sidebar by pressing `ctrl+b`. This feature provides more screen real estate for the main chat view, which is especially useful on smaller screens. The implementation involves: - Adding a `hidden` state to the `sidebarCmp` component. - Creating a `ToggleSidebarMsg` to toggle the sidebar's visibility. - Adding a keybinding (`ctrl+b`) to the chat page to dispatch the message. - Modifying the `splitPaneLayout` to forward the message to the sidebar. 🤖 Generated with opencode Co-Authored-By: opencode --- internal/tui/components/chat/sidebar.go | 11 +++++++++++ internal/tui/layout/split.go | 12 ++++++++++++ internal/tui/page/chat.go | 7 +++++++ 3 files changed, 30 insertions(+) diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index a66249b368..4c34bd4c0c 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -17,6 +17,11 @@ import ( "github.com/opencode-ai/opencode/internal/tui/theme" ) +type ToggleSidebarMsg struct{} + +func (t ToggleSidebarMsg) ToggleSidebar() {} + + type sidebarCmp struct { width, height int session session.Session @@ -25,6 +30,7 @@ type sidebarCmp struct { additions int removals int } + hidden bool } func (m *sidebarCmp) Init() tea.Cmd { @@ -58,6 +64,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ctx := context.Background() m.loadModifiedFiles(ctx) } + case ToggleSidebarMsg: + m.hidden = !m.hidden case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { if m.session.ID == msg.Payload.ID { @@ -82,6 +90,9 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *sidebarCmp) View() string { + if m.hidden { + return "" + } baseStyle := styles.BaseStyle() return baseStyle. diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index 2684a8447c..86629c2620 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -56,6 +56,18 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return s, s.SetSize(msg.Width, msg.Height) + default: + // Check if the message is a ToggleSidebarMsg and forward it to the right panel + if s.rightPanel != nil { + if _, ok := msg.(interface{ ToggleSidebar() }); ok { + u, cmd := s.rightPanel.Update(msg) + s.rightPanel = u.(Container) + if cmd != nil { + cmds = append(cmds, cmd) + } + return s, tea.Batch(cmds...) + } + } } if s.rightPanel != nil { diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index d297a34c2c..37c60d6db0 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -33,6 +33,7 @@ type ChatKeyMap struct { ShowCompletionDialog key.Binding NewSession key.Binding Cancel key.Binding + ToggleSidebar key.Binding } var keyMap = ChatKeyMap{ @@ -48,6 +49,10 @@ var keyMap = ChatKeyMap{ key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), + ToggleSidebar: key.NewBinding( + key.WithKeys("ctrl+b"), + key.WithHelp("ctrl+b", "toggle sidebar"), + ), } func (p *chatPage) Init() tea.Cmd { @@ -118,6 +123,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.app.CoderAgent.Cancel(p.session.ID) return p, nil } + case key.Matches(msg, keyMap.ToggleSidebar): + return p, util.CmdHandler(chat.ToggleSidebarMsg{}) } } if p.showCompletionDialog { From 021cb2f94eb3d50d2d313985512239c6946ea657 Mon Sep 17 00:00:00 2001 From: molander Date: Sun, 8 Jun 2025 10:07:18 +0000 Subject: [PATCH 07/11] Add OpenAI-compatible provider support via environment variables --- internal/config/config.go | 38 +++++++++++ internal/llm/agent/agent.go | 122 +++++++++++++++++++++--------------- 2 files changed, 109 insertions(+), 51 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5a0905bba2..3a5bb5f1b8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -629,6 +629,44 @@ func getProviderAPIKey(provider models.ModelProvider) string { return "" } + +// GetOpenAIBaseURL gets the base URL override for OpenAI-compatible providers +func GetOpenAIBaseURL() string { + return os.Getenv("OPENAI_BASE_URL") +} + +// GetOpenAIModelOverride gets the model name override for OpenAI-compatible providers +func GetOpenAIModelOverride() string { + return os.Getenv("OPENAI_MODEL_OVERRIDE") +} + +// GetOpenAIReasoningEffort gets the reasoning effort level +func GetOpenAIReasoningEffort() string { + effort := os.Getenv("OPENAI_REASONING_EFFORT") + if effort == "" { + return "medium" // default + } + return effort +} + +// GetOpenAIExtraHeaders parses extra headers from environment +func GetOpenAIExtraHeaders() map[string]string { + headersStr := os.Getenv("OPENAI_EXTRA_HEADERS") + if headersStr == "" { + return nil + } + + headers := make(map[string]string) + pairs := strings.Split(headersStr, ",") + for _, pair := range pairs { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) == 2 { + headers[kv[0]] = kv[1] + } + } + return headers +} + // setDefaultModelForAgent sets a default model for an agent based on available providers func setDefaultModelForAgent(agent AgentName) bool { // Check providers in order of preference diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 4f31fe75d6..16bef754ab 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -688,55 +688,75 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error { } func createAgentProvider(agentName config.AgentName) (provider.Provider, error) { - cfg := config.Get() - agentConfig, ok := cfg.Agents[agentName] - if !ok { - return nil, fmt.Errorf("agent %s not found", agentName) - } - model, ok := models.SupportedModels[agentConfig.Model] - if !ok { - return nil, fmt.Errorf("model %s not supported", agentConfig.Model) - } - - providerCfg, ok := cfg.Providers[model.Provider] - if !ok { - return nil, fmt.Errorf("provider %s not supported", model.Provider) - } - if providerCfg.Disabled { - return nil, fmt.Errorf("provider %s is not enabled", model.Provider) - } - maxTokens := model.DefaultMaxTokens - if agentConfig.MaxTokens > 0 { - maxTokens = agentConfig.MaxTokens - } - opts := []provider.ProviderClientOption{ - provider.WithAPIKey(providerCfg.APIKey), - provider.WithModel(model), - provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)), - provider.WithMaxTokens(maxTokens), - } - if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason { - opts = append( - opts, - provider.WithOpenAIOptions( - provider.WithReasoningEffort(agentConfig.ReasoningEffort), - ), - ) - } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder { - opts = append( - opts, - provider.WithAnthropicOptions( - provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn), - ), - ) - } - agentProvider, err := provider.NewProvider( - model.Provider, - opts..., - ) - if err != nil { - return nil, fmt.Errorf("could not create provider: %v", err) - } - - return agentProvider, nil + cfg := config.Get() + agentConfig, ok := cfg.Agents[agentName] + if !ok { + return nil, fmt.Errorf("agent %s not found", agentName) + } + model, ok := models.SupportedModels[agentConfig.Model] + if !ok { + return nil, fmt.Errorf("model %s not supported", agentConfig.Model) + } + providerCfg, ok := cfg.Providers[model.Provider] + if !ok { + return nil, fmt.Errorf("provider %s not supported", model.Provider) + } + if providerCfg.Disabled { + return nil, fmt.Errorf("provider %s is not enabled", model.Provider) + } + maxTokens := model.DefaultMaxTokens + if agentConfig.MaxTokens > 0 { + maxTokens = agentConfig.MaxTokens + } + opts := []provider.ProviderClientOption{ + provider.WithAPIKey(providerCfg.APIKey), + provider.WithModel(model), + provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)), + provider.WithMaxTokens(maxTokens), + } + + // Handle OpenAI and OpenAI-compatible providers + if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason { + openAIOptions := []provider.OpenAIOption{ + provider.WithReasoningEffort(agentConfig.ReasoningEffort), + } + + // Add environment variable overrides for OpenAI-compatible providers + if baseURL := config.GetOpenAIBaseURL(); baseURL != "" { + openAIOptions = append(openAIOptions, provider.WithOpenAIBaseURL(baseURL)) + } + + if modelOverride := config.GetOpenAIModelOverride(); modelOverride != "" { + openAIOptions = append(openAIOptions, provider.WithOpenAIModelOverride(modelOverride)) + } + + if headers := config.GetOpenAIExtraHeaders(); headers != nil { + openAIOptions = append(openAIOptions, provider.WithOpenAIExtraHeaders(headers)) + } + + // Override reasoning effort from env var if provided + if envEffort := config.GetOpenAIReasoningEffort(); envEffort != "medium" { + // Replace the reasoning effort with env var value + openAIOptions[0] = provider.WithReasoningEffort(envEffort) + } + + opts = append(opts, provider.WithOpenAIOptions(openAIOptions...)) + + } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder { + opts = append( + opts, + provider.WithAnthropicOptions( + provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn), + ), + ) + } + + agentProvider, err := provider.NewProvider( + model.Provider, + opts..., + ) + if err != nil { + return nil, fmt.Errorf("could not create provider: %v", err) + } + return agentProvider, nil } From 5697e17b284ca1df28d95c704adf88a72a90ef9f Mon Sep 17 00:00:00 2001 From: molander Date: Sun, 8 Jun 2025 17:53:24 +0000 Subject: [PATCH 08/11] fixed up some styling in agent file --- internal/llm/agent/agent.go | 34 ++++++++-------------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 16bef754ab..21670b02f4 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -717,31 +717,12 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error) // Handle OpenAI and OpenAI-compatible providers if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason { - openAIOptions := []provider.OpenAIOption{ - provider.WithReasoningEffort(agentConfig.ReasoningEffort), - } - - // Add environment variable overrides for OpenAI-compatible providers - if baseURL := config.GetOpenAIBaseURL(); baseURL != "" { - openAIOptions = append(openAIOptions, provider.WithOpenAIBaseURL(baseURL)) - } - - if modelOverride := config.GetOpenAIModelOverride(); modelOverride != "" { - openAIOptions = append(openAIOptions, provider.WithOpenAIModelOverride(modelOverride)) - } - - if headers := config.GetOpenAIExtraHeaders(); headers != nil { - openAIOptions = append(openAIOptions, provider.WithOpenAIExtraHeaders(headers)) - } - - // Override reasoning effort from env var if provided - if envEffort := config.GetOpenAIReasoningEffort(); envEffort != "medium" { - // Replace the reasoning effort with env var value - openAIOptions[0] = provider.WithReasoningEffort(envEffort) - } - - opts = append(opts, provider.WithOpenAIOptions(openAIOptions...)) - + opts = append( + opts, + provider.WithOpenAIOptions( + provider.WithReasoningEffort(agentConfig.ReasoningEffort), + ), + ) } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder { opts = append( opts, @@ -750,7 +731,7 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error) ), ) } - + agentProvider, err := provider.NewProvider( model.Provider, opts..., @@ -758,5 +739,6 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error) if err != nil { return nil, fmt.Errorf("could not create provider: %v", err) } + return agentProvider, nil } From 0026778aa8b9c62168fa1772afba6ca05ff1117a Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 9 Jun 2025 14:16:00 +0700 Subject: [PATCH 09/11] feat(tui): expand chat view when sidebar is hidden When the sidebar is hidden, the chat view now expands to fill the available space. This is achieved by updating the layout to recalculate its dimensions when the sidebar's visibility changes. This also fixes a bug where the status bar would overlap the chat input when the sidebar was hidden. --- internal/tui/bindings/bindings.go | 20 +++++++ internal/tui/components/chat/sidebar.go | 8 +-- internal/tui/components/dialog/commands.go | 6 +-- internal/tui/components/util/simple-list.go | 6 +-- internal/tui/events/events.go | 6 +++ internal/tui/layout/split.go | 58 ++++++++++----------- internal/tui/page/chat.go | 3 +- 7 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 internal/tui/bindings/bindings.go create mode 100644 internal/tui/events/events.go diff --git a/internal/tui/bindings/bindings.go b/internal/tui/bindings/bindings.go new file mode 100644 index 0000000000..550d9fd516 --- /dev/null +++ b/internal/tui/bindings/bindings.go @@ -0,0 +1,20 @@ +package bindings + +import "github.com/charmbracelet/bubbles/key" + +type Bindings interface { + BindingKeys() []key.Binding +} + +func KeyMapToSlice(km any) []key.Binding { + switch km := km.(type) { + case map[string]key.Binding: + bindings := make([]key.Binding, 0, len(km)) + for _, v := range km { + bindings = append(bindings, v) + } + return bindings + default: + return nil + } +} diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index 4c34bd4c0c..17058b4e37 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -15,13 +15,9 @@ import ( "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/events" ) -type ToggleSidebarMsg struct{} - -func (t ToggleSidebarMsg) ToggleSidebar() {} - - type sidebarCmp struct { width, height int session session.Session @@ -64,7 +60,7 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ctx := context.Background() m.loadModifiedFiles(ctx) } - case ToggleSidebarMsg: + case events.ToggleSidebarMsg: m.hidden = !m.hidden case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go index 25069b8a6d..10079c1000 100644 --- a/internal/tui/components/dialog/commands.go +++ b/internal/tui/components/dialog/commands.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" - "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/bindings" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" @@ -57,7 +57,7 @@ type CloseCommandDialogMsg struct{} // CommandDialog interface for the command selection dialog type CommandDialog interface { tea.Model - layout.Bindings + bindings.Bindings SetCommands(commands []Command) } @@ -159,7 +159,7 @@ func (c *commandDialogCmp) View() string { } func (c *commandDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(commandKeys) + return bindings.KeyMapToSlice(commandKeys) } func (c *commandDialogCmp) SetCommands(commands []Command) { diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index 7aad2494c6..86de54eb7a 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -4,7 +4,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/bindings" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" ) @@ -15,7 +15,7 @@ type SimpleListItem interface { type SimpleList[T SimpleListItem] interface { tea.Model - layout.Bindings + bindings.Bindings SetMaxWidth(maxWidth int) GetSelectedItem() (item T, idx int) SetItems(items []T) @@ -84,7 +84,7 @@ func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (c *simpleListCmp[T]) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(simpleListKeys) + return bindings.KeyMapToSlice(simpleListKeys) } func (c *simpleListCmp[T]) GetSelectedItem() (T, int) { diff --git a/internal/tui/events/events.go b/internal/tui/events/events.go new file mode 100644 index 0000000000..b52bad42e5 --- /dev/null +++ b/internal/tui/events/events.go @@ -0,0 +1,6 @@ + +package events + +type ToggleSidebarMsg struct{} + +func (t ToggleSidebarMsg) ToggleSidebar() {} diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index 86629c2620..06e5d3555a 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -4,6 +4,8 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/tui/bindings" + "github.com/opencode-ai/opencode/internal/tui/events" "github.com/opencode-ai/opencode/internal/tui/theme" ) @@ -18,6 +20,7 @@ type SplitPaneLayout interface { ClearLeftPanel() tea.Cmd ClearRightPanel() tea.Cmd ClearBottomPanel() tea.Cmd + IsRightPanelHidden() bool } type splitPaneLayout struct { @@ -29,6 +32,7 @@ type splitPaneLayout struct { rightPanel Container leftPanel Container bottomPanel Container + hidden bool } type SplitPaneOption func(*splitPaneLayout) @@ -56,19 +60,10 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return s, s.SetSize(msg.Width, msg.Height) - default: - // Check if the message is a ToggleSidebarMsg and forward it to the right panel - if s.rightPanel != nil { - if _, ok := msg.(interface{ ToggleSidebar() }); ok { - u, cmd := s.rightPanel.Update(msg) - s.rightPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - return s, tea.Batch(cmds...) - } - } - } + case events.ToggleSidebarMsg: + s.hidden = !s.hidden + return s, s.SetSize(s.width, s.height) + } if s.rightPanel != nil { u, cmd := s.rightPanel.Update(msg) @@ -100,10 +95,10 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (s *splitPaneLayout) View() string { var topSection string - if s.leftPanel != nil && s.rightPanel != nil { + if s.leftPanel != nil && s.rightPanel != nil && !s.hidden { leftView := s.leftPanel.View() rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) + topSection = lipgloss.JoinHorizontal(lipgloss.Bottom, leftView, rightView) } else if s.leftPanel != nil { topSection = s.leftPanel.View() } else if s.rightPanel != nil { @@ -147,11 +142,15 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { bottomHeight = height - topHeight } else { topHeight = height - bottomHeight = 0 + bottomHeight = 0 } var leftWidth, rightWidth int - if s.leftPanel != nil && s.rightPanel != nil { + if s.hidden { + leftWidth = width + rightWidth = 0 + } + if s.leftPanel != nil && s.rightPanel != nil && !s.hidden { leftWidth = int(float64(width) * s.ratio) rightWidth = width - leftWidth } else if s.leftPanel != nil { @@ -168,7 +167,7 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { cmds = append(cmds, cmd) } - if s.rightPanel != nil { + if s.rightPanel != nil && !s.hidden { cmd := s.rightPanel.SetSize(rightWidth, topHeight) cmds = append(cmds, cmd) } @@ -184,6 +183,10 @@ func (s *splitPaneLayout) GetSize() (int, int) { return s.width, s.height } +func (s *splitPaneLayout) IsRightPanelHidden() bool { + return s.hidden +} + func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd { s.leftPanel = panel if s.width > 0 && s.height > 0 { @@ -234,21 +237,16 @@ func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd { func (s *splitPaneLayout) BindingKeys() []key.Binding { keys := []key.Binding{} - if s.leftPanel != nil { - if b, ok := s.leftPanel.(Bindings); ok { - keys = append(keys, b.BindingKeys()...) - } + if b, ok := s.leftPanel.(bindings.Bindings); ok { + keys = append(keys, b.BindingKeys()...) } - if s.rightPanel != nil { - if b, ok := s.rightPanel.(Bindings); ok { - keys = append(keys, b.BindingKeys()...) - } + if b, ok := s.rightPanel.(bindings.Bindings); ok { + keys = append(keys, b.BindingKeys()...) } - if s.bottomPanel != nil { - if b, ok := s.bottomPanel.(Bindings); ok { - keys = append(keys, b.BindingKeys()...) - } + if b, ok := s.bottomPanel.(bindings.Bindings); ok { + keys = append(keys, b.BindingKeys()...) } + return keys } diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 37c60d6db0..9198fa6306 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -13,6 +13,7 @@ import ( "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/dialog" + "github.com/opencode-ai/opencode/internal/tui/events" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -124,7 +125,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, nil } case key.Matches(msg, keyMap.ToggleSidebar): - return p, util.CmdHandler(chat.ToggleSidebarMsg{}) + return p, util.CmdHandler(events.ToggleSidebarMsg{}) } } if p.showCompletionDialog { From fc93669b5b5db4c26251ddba4eeff92dea34cf59 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 9 Jun 2025 19:01:42 +0700 Subject: [PATCH 10/11] fix: remove newline --- internal/tui/events/events.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/events/events.go b/internal/tui/events/events.go index b52bad42e5..0b1a363ec5 100644 --- a/internal/tui/events/events.go +++ b/internal/tui/events/events.go @@ -1,4 +1,3 @@ - package events type ToggleSidebarMsg struct{} From 36d234ca074f0182dd2e60c276798bc38008eb93 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Tue, 10 Jun 2025 19:33:11 +0700 Subject: [PATCH 11/11] feat(tui): require double escape to cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a confirmation step for canceling an ongoing agent operation. Users must now press the `esc` key twice in quick succession to cancel, preventing accidental cancellations. The implementation involves: - Adding a timeout to the `esc` key press in the chat page. - Passing the `firstEsc` state down to the message list component to update the help message. - Adding a `Model()` method to the `layout.Container` to allow the chat page to access the message list's model. 🤖 Generated with opencode Co-Authored-By: opencode --- internal/tui/components/chat/list.go | 42 +++++++++++++++++----------- internal/tui/layout/container.go | 5 ++++ internal/tui/page/chat.go | 29 +++++++++++++++---- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 40d5b96287..5c555bc9aa 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -24,7 +25,7 @@ type cacheItem struct { width int content []uiMessage } -type messagesCmp struct { +type MessagesCmp struct { app *app.App width, height int viewport viewport.Model @@ -36,6 +37,7 @@ type messagesCmp struct { spinner spinner.Model rendering bool attachments viewport.Model + firstEsc time.Time } type renderFinishedMsg struct{} @@ -65,11 +67,11 @@ var messageKeys = MessageKeys{ ), } -func (m *messagesCmp) Init() tea.Cmd { +func (m *MessagesCmp) Init() tea.Cmd { return tea.Batch(m.viewport.Init(), m.spinner.Tick) } -func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *MessagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case dialog.ThemeChangedMsg: @@ -168,7 +170,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *messagesCmp) IsAgentWorking() bool { +func (m *MessagesCmp) IsAgentWorking() bool { return m.app.CoderAgent.IsSessionBusy(m.session.ID) } @@ -184,7 +186,7 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string { return fmt.Sprintf("%dm%ds", minutes, seconds) } -func (m *messagesCmp) renderView() { +func (m *MessagesCmp) renderView() { m.uiMessages = make([]uiMessage, 0) pos := 0 baseStyle := styles.BaseStyle() @@ -262,7 +264,7 @@ func (m *messagesCmp) renderView() { ) } -func (m *messagesCmp) View() string { +func (m *MessagesCmp) View() string { baseStyle := styles.BaseStyle() if m.rendering { @@ -345,7 +347,7 @@ func hasUnfinishedToolCalls(messages []message.Message) bool { return false } -func (m *messagesCmp) working() string { +func (m *MessagesCmp) working() string { text := "" if m.IsAgentWorking() && len(m.messages) > 0 { t := theme.CurrentTheme() @@ -371,18 +373,22 @@ func (m *messagesCmp) working() string { return text } -func (m *messagesCmp) help() string { +func (m *MessagesCmp) help() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() text := "" if m.app.CoderAgent.IsBusy() { + msg := " to cancel" + if !m.firstEsc.IsZero() { + msg = " again to cancel" + } text += lipgloss.JoinHorizontal( lipgloss.Left, baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), baseStyle.Foreground(t.Text()).Bold(true).Render("esc"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"), + baseStyle.Foreground(t.TextMuted()).Bold(true).Render(msg), ) } else { text += lipgloss.JoinHorizontal( @@ -400,7 +406,7 @@ func (m *messagesCmp) help() string { Render(text) } -func (m *messagesCmp) initialScreen() string { +func (m *MessagesCmp) initialScreen() string { baseStyle := styles.BaseStyle() return baseStyle.Width(m.width).Render( @@ -413,14 +419,14 @@ func (m *messagesCmp) initialScreen() string { ) } -func (m *messagesCmp) rerender() { +func (m *MessagesCmp) rerender() { for _, msg := range m.messages { delete(m.cachedContent, msg.ID) } m.renderView() } -func (m *messagesCmp) SetSize(width, height int) tea.Cmd { +func (m *MessagesCmp) SetSize(width, height int) tea.Cmd { if m.width == width && m.height == height { return nil } @@ -434,11 +440,11 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd { return nil } -func (m *messagesCmp) GetSize() (int, int) { +func (m *MessagesCmp) GetSize() (int, int) { return m.width, m.height } -func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { +func (m *MessagesCmp) SetSession(session session.Session) tea.Cmd { if m.session.ID == session.ID { return nil } @@ -459,7 +465,7 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { } } -func (m *messagesCmp) BindingKeys() []key.Binding { +func (m *MessagesCmp) BindingKeys() []key.Binding { return []key.Binding{ m.viewport.KeyMap.PageDown, m.viewport.KeyMap.PageUp, @@ -468,6 +474,10 @@ func (m *messagesCmp) BindingKeys() []key.Binding { } } +func (m *MessagesCmp) SetFirstEsc(t time.Time) { + m.firstEsc = t +} + func NewMessagesCmp(app *app.App) tea.Model { s := spinner.New() s.Spinner = spinner.Pulse @@ -477,7 +487,7 @@ func NewMessagesCmp(app *app.App) tea.Model { vp.KeyMap.PageDown = messageKeys.PageDown vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown - return &messagesCmp{ + return &MessagesCmp{ app: app, cachedContent: make(map[string]cacheItem), viewport: vp, diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go index 83aef58793..d8f00dfa3f 100644 --- a/internal/tui/layout/container.go +++ b/internal/tui/layout/container.go @@ -11,6 +11,7 @@ type Container interface { tea.Model Sizeable Bindings + Model() tea.Model } type container struct { width int @@ -121,6 +122,10 @@ func (c *container) BindingKeys() []key.Binding { return []key.Binding{} } +func (c *container) Model() tea.Model { + return c.content +} + type ContainerOption func(*container) func NewContainer(content tea.Model, options ...ContainerOption) Container { diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index d297a34c2c..0e529a3296 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -4,6 +4,8 @@ import ( "context" "strings" + "time" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -27,8 +29,11 @@ type chatPage struct { session session.Session completionDialog dialog.CompletionDialog showCompletionDialog bool + firstEsc time.Time } +type firstEscTimedOutMsg struct{} + type ChatKeyMap struct { ShowCompletionDialog key.Binding NewSession key.Binding @@ -104,7 +109,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keyMap.ShowCompletionDialog): p.showCompletionDialog = true - // Continue sending keys to layout->chat + // Continue sending keys to layout->chat case key.Matches(msg, keyMap.NewSession): p.session = session.Session{} return p, tea.Batch( @@ -112,13 +117,20 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.CmdHandler(chat.SessionClearedMsg{}), ) case key.Matches(msg, keyMap.Cancel): - if p.session.ID != "" { - // Cancel the current session's generation process - // This allows users to interrupt long-running operations - p.app.CoderAgent.Cancel(p.session.ID) - return p, nil + if p.app.CoderAgent.IsBusy() { + if p.firstEsc.IsZero() { + p.firstEsc = time.Now() + cmds = append(cmds, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return firstEscTimedOutMsg{} + })) + } else { + p.firstEsc = time.Time{} + p.app.CoderAgent.Cancel(p.session.ID) + } } } + case firstEscTimedOutMsg: + p.firstEsc = time.Time{} } if p.showCompletionDialog { context, contextCmd := p.completionDialog.Update(msg) @@ -132,6 +144,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + if p.messages.Model() != nil { + if messagesCmp, ok := p.messages.Model().(*chat.MessagesCmp); ok { + messagesCmp.SetFirstEsc(p.firstEsc) + } + } u, cmd := p.layout.Update(msg) cmds = append(cmds, cmd)