summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-10 09:53:18 -0500
committeradamdottv <[email protected]>2025-07-10 10:06:51 -0500
commitce4cb820f72591d58ea78d1c0d955a7ca50a0217 (patch)
treeeaf4d26f01c7bd6f4c737cabfd6484ab5abd3401 /packages/tui/internal
parentba5be6b6257ea06302db70e3f706e0e29359a77d (diff)
downloadopencode-ce4cb820f72591d58ea78d1c0d955a7ca50a0217.tar.gz
opencode-ce4cb820f72591d58ea78d1c0d955a7ca50a0217.zip
feat(tui): modes
Diffstat (limited to 'packages/tui/internal')
-rw-r--r--packages/tui/internal/app/app.go95
-rw-r--r--packages/tui/internal/commands/command.go7
-rw-r--r--packages/tui/internal/components/status/status.go135
-rw-r--r--packages/tui/internal/config/config.go21
-rw-r--r--packages/tui/internal/tui/tui.go11
5 files changed, 215 insertions, 54 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index bd16036af..b41bbaeaf 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -23,11 +23,15 @@ import (
type App struct {
Info opencode.App
+ Modes []opencode.Mode
+ Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
+ ModeIndex int
+ Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
@@ -64,6 +68,7 @@ func New(
ctx context.Context,
version string,
appInfo opencode.App,
+ modes []opencode.Mode,
httpClient *opencode.Client,
model *string,
prompt *string,
@@ -87,14 +92,33 @@ func New(
config.SaveState(appStatePath, appState)
}
+ if appState.ModeModel == nil {
+ appState.ModeModel = make(map[string]config.ModeModel)
+ }
+
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
- if configInfo.Model != "" {
- splits := strings.Split(configInfo.Model, "/")
- appState.Provider = splits[0]
- appState.Model = strings.Join(splits[1:], "/")
+ var modeIndex int
+ var mode *opencode.Mode
+ modeName := "build"
+ if appState.Mode != "" {
+ modeName = appState.Mode
+ }
+ for i, m := range modes {
+ if m.Name == modeName {
+ modeIndex = i
+ break
+ }
+ }
+ mode = &modes[modeIndex]
+
+ if mode.Model.ModelID != "" {
+ appState.ModeModel[mode.Name] = config.ModeModel{
+ ProviderID: mode.Model.ProviderID,
+ ModelID: mode.Model.ModelID,
+ }
}
if err := theme.LoadThemesFromDirectories(
@@ -119,11 +143,14 @@ func New(
app := &App{
Info: appInfo,
+ Modes: modes,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
+ ModeIndex: modeIndex,
+ Mode: mode,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
@@ -162,6 +189,45 @@ func (a *App) SetClipboard(text string) tea.Cmd {
return tea.Sequence(cmds...)
}
+func (a *App) SwitchMode() (*App, tea.Cmd) {
+ a.ModeIndex++
+ if a.ModeIndex >= len(a.Modes) {
+ a.ModeIndex = 0
+ }
+ a.Mode = &a.Modes[a.ModeIndex]
+
+ modelID := a.Mode.Model.ModelID
+ providerID := a.Mode.Model.ProviderID
+ if modelID == "" {
+ if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
+ modelID = model.ModelID
+ providerID = model.ProviderID
+ }
+ }
+
+ if modelID != "" {
+ for _, provider := range a.Providers {
+ if provider.ID == providerID {
+ a.Provider = &provider
+ for _, model := range provider.Models {
+ if model.ID == modelID {
+ a.Model = &model
+ break
+ }
+ }
+ break
+ }
+ }
+ }
+
+ a.State.Mode = a.Mode.Name
+
+ return a, func() tea.Msg {
+ a.SaveState()
+ return nil
+ }
+}
+
func (a *App) InitializeProvider() tea.Cmd {
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
@@ -198,6 +264,14 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil
}
+ a.Providers = providers
+
+ // retains backwards compatibility with old state format
+ if model, ok := a.State.ModeModel[a.State.Mode]; ok {
+ a.State.Provider = model.ProviderID
+ a.State.Model = model.ModelID
+ }
+
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
@@ -322,10 +396,14 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd {
a.compactCancel = nil
}()
- _, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- })
+ _, err := a.Client.Session.Summarize(
+ compactCtx,
+ a.Session.ID,
+ opencode.SessionSummarizeParams{
+ ProviderID: opencode.F(a.Provider.ID),
+ ModelID: opencode.F(a.Model.ID),
+ },
+ )
if err != nil {
if compactCtx.Err() != context.Canceled {
slog.Error("Failed to compact session", "error", err)
@@ -417,6 +495,7 @@ func (a *App) SendChatMessage(
Parts: opencode.F(parts),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
+ Mode: opencode.F(a.Mode.Name),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index 791f74759..1659adb85 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -86,6 +86,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
const (
AppHelpCommand CommandName = "app_help"
+ SwitchModeCommand CommandName = "switch_mode"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
@@ -153,6 +154,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"help"},
},
{
+ Name: SwitchModeCommand,
+ Description: "switch mode",
+ Keybindings: parseBindings("tab"),
+ Trigger: []string{"mode"},
+ },
+ {
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go
index d0d61b173..0809114d0 100644
--- a/packages/tui/internal/components/status/status.go
+++ b/packages/tui/internal/components/status/status.go
@@ -7,8 +7,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode-sdk-go"
+ "github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -55,7 +56,12 @@ func (m statusComponent) logo() string {
Render(open + code + version)
}
-func formatTokensAndCost(tokens float64, contextWindow float64, cost float64, isSubscriptionModel bool) string {
+func formatTokensAndCost(
+ tokens float64,
+ contextWindow float64,
+ cost float64,
+ isSubscriptionModel bool,
+) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@@ -104,50 +110,103 @@ func (m statusComponent) View() string {
Padding(0, 1).
Render(m.cwd)
- sessionInfo := ""
- if m.app.Session.ID != "" {
- tokens := float64(0)
- cost := float64(0)
- contextWindow := m.app.Model.Limit.Context
-
- for _, message := range m.app.Messages {
- if assistant, ok := message.(opencode.AssistantMessage); ok {
- cost += assistant.Cost
- usage := assistant.Tokens
- if usage.Output > 0 {
- if assistant.Summary {
- tokens = usage.Output
- continue
- }
- tokens = (usage.Input +
- usage.Cache.Write +
- usage.Cache.Read +
- usage.Output +
- usage.Reasoning)
- }
- }
- }
-
- // Check if current model is a subscription model (cost is 0 for both input and output)
- isSubscriptionModel := m.app.Model != nil &&
- m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
-
- sessionInfo = styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundElement()).
- Padding(0, 1).
- Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
+ // sessionInfo := ""
+ // if m.app.Session.ID != "" {
+ // tokens := float64(0)
+ // cost := float64(0)
+ // contextWindow := m.app.Model.Limit.Context
+ //
+ // for _, message := range m.app.Messages {
+ // if assistant, ok := message.(opencode.AssistantMessage); ok {
+ // cost += assistant.Cost
+ // usage := assistant.Tokens
+ // if usage.Output > 0 {
+ // if assistant.Summary {
+ // tokens = usage.Output
+ // continue
+ // }
+ // tokens = (usage.Input +
+ // usage.Cache.Write +
+ // usage.Cache.Read +
+ // usage.Output +
+ // usage.Reasoning)
+ // }
+ // }
+ // }
+ //
+ // // Check if current model is a subscription model (cost is 0 for both input and output)
+ // isSubscriptionModel := m.app.Model != nil &&
+ // m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
+ //
+ // sessionInfo = styles.NewStyle().
+ // Foreground(t.TextMuted()).
+ // Background(t.BackgroundElement()).
+ // Padding(0, 1).
+ // Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
+ // }
+
+ var modeBackground compat.AdaptiveColor
+ var modeForeground compat.AdaptiveColor
+ switch m.app.ModeIndex {
+ case 0:
+ modeBackground = t.BackgroundElement()
+ modeForeground = t.TextMuted()
+ case 1:
+ modeBackground = t.Secondary()
+ modeForeground = t.BackgroundPanel()
+ case 2:
+ modeBackground = t.Accent()
+ modeForeground = t.BackgroundPanel()
+ case 3:
+ modeBackground = t.Success()
+ modeForeground = t.BackgroundPanel()
+ case 4:
+ modeBackground = t.Warning()
+ modeForeground = t.BackgroundPanel()
+ case 5:
+ modeBackground = t.Primary()
+ modeForeground = t.BackgroundPanel()
+ case 6:
+ modeBackground = t.Error()
+ modeForeground = t.BackgroundPanel()
+ default:
+ modeBackground = t.Secondary()
+ modeForeground = t.BackgroundPanel()
+ }
+
+ command := m.app.Commands[commands.SwitchModeCommand]
+ kb := command.Keybindings[0]
+ key := kb.Key
+ if kb.RequiresLeader {
+ key = m.app.Config.Keybinds.Leader + " " + kb.Key
}
- // diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
+ modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
+ modeNameStyle := modeStyle.Bold(true).Render
+ modeDescStyle := modeStyle.Render
+ mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
+ mode = modeStyle.
+ Padding(0, 1).
+ BorderLeft(true).
+ BorderStyle(lipgloss.ThickBorder()).
+ BorderForeground(modeBackground).
+ BorderBackground(t.BackgroundPanel()).
+ Render(mode)
+
+ mode = styles.NewStyle().
+ Faint(true).
+ Background(t.BackgroundPanel()).
+ Foreground(t.TextMuted()).
+ Render(key+" ") +
+ mode
space := max(
0,
- m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
+ m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
)
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
- status := logo + cwd + spacer + sessionInfo
+ status := logo + cwd + spacer + mode
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go
index 3dd6fcf59..7004b85b1 100644
--- a/packages/tui/internal/config/config.go
+++ b/packages/tui/internal/config/config.go
@@ -16,18 +16,27 @@ type ModelUsage struct {
LastUsed time.Time `toml:"last_used"`
}
+type ModeModel struct {
+ ProviderID string `toml:"provider_id"`
+ ModelID string `toml:"model_id"`
+}
+
type State struct {
- Theme string `toml:"theme"`
- Provider string `toml:"provider"`
- Model string `toml:"model"`
- RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
- MessagesRight bool `toml:"messages_right"`
- SplitDiff bool `toml:"split_diff"`
+ Theme string `toml:"theme"`
+ ModeModel map[string]ModeModel `toml:"mode_model"`
+ Provider string `toml:"provider"`
+ Model string `toml:"model"`
+ Mode string `toml:"mode"`
+ RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
+ MessagesRight bool `toml:"messages_right"`
+ SplitDiff bool `toml:"split_diff"`
}
func NewState() *State {
return &State{
Theme: "opencode",
+ Mode: "build",
+ ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
}
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 770e8ac01..0a075a146 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -23,6 +23,7 @@ import (
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
+ "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -524,8 +525,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
- a.app.State.Provider = msg.Provider.ID
- a.app.State.Model = msg.Model.ID
+ a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
+ ProviderID: msg.Provider.ID,
+ ModelID: msg.Model.ID,
+ }
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
@@ -823,6 +826,10 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
+ case commands.SwitchModeCommand:
+ updated, cmd := a.app.SwitchMode()
+ a.app = updated
+ cmds = append(cmds, cmd)
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")