diff --git a/cmd/root.go b/cmd/root.go index 3a58cec4ed..dbf781c914 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") { + config.Get().AlwaysAllowPermissions = 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..e683d840aa 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") @@ -629,6 +631,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 @@ -874,7 +914,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/llm/agent/agent.go b/internal/llm/agent/agent.go index 4f31fe75d6..21670b02f4 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -688,55 +688,57 @@ 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 { + 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 } diff --git a/internal/llm/models/gemini.go b/internal/llm/models/gemini.go index 794ec3f0a0..fed41da88f 100644 --- a/internal/llm/models/gemini.go +++ b/internal/llm/models/gemini.go @@ -15,9 +15,9 @@ 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, + CostPer1MInCached: 0.0375, CostPer1MOutCached: 0, CostPer1MOut: 0.60, ContextWindow: 1000000, @@ -28,9 +28,9 @@ 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, + CostPer1MInCached: 0.31, CostPer1MOutCached: 0, CostPer1MOut: 10, ContextWindow: 1000000, 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, 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 } 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/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/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index a66249b368..17058b4e37 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -15,6 +15,7 @@ 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 sidebarCmp struct { @@ -25,6 +26,7 @@ type sidebarCmp struct { additions int removals int } + hidden bool } func (m *sidebarCmp) Init() tea.Cmd { @@ -58,6 +60,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ctx := context.Background() m.loadModifiedFiles(ctx) } + case events.ToggleSidebarMsg: + m.hidden = !m.hidden case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { if m.session.ID == msg.Payload.ID { @@ -82,6 +86,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/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..0b1a363ec5 --- /dev/null +++ b/internal/tui/events/events.go @@ -0,0 +1,5 @@ +package events + +type ToggleSidebarMsg struct{} + +func (t ToggleSidebarMsg) ToggleSidebar() {} 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/layout/split.go b/internal/tui/layout/split.go index 2684a8447c..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,7 +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) - } + 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) @@ -88,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 { @@ -135,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 { @@ -156,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) } @@ -172,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 { @@ -222,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 d297a34c2c..1528b16242 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" @@ -13,6 +15,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" ) @@ -27,12 +30,16 @@ 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 Cancel key.Binding + ToggleSidebar key.Binding } var keyMap = ChatKeyMap{ @@ -48,6 +55,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 { @@ -104,7 +115,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 +123,22 @@ 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 key.Matches(msg, keyMap.ToggleSidebar): + return p, util.CmdHandler(events.ToggleSidebarMsg{}) } + case firstEscTimedOutMsg: + p.firstEsc = time.Time{} } if p.showCompletionDialog { context, contextCmd := p.completionDialog.Update(msg) @@ -132,6 +152,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)