Explorar o código

feat(tui): modes

adamdottv hai 7 meses
pai
achega
ce4cb820f7

+ 2 - 0
packages/opencode/src/cli/cmd/tui.ts

@@ -11,6 +11,7 @@ import { Config } from "../../config/config"
 import { Bus } from "../../bus"
 import { Log } from "../../util/log"
 import { FileWatcher } from "../../file/watch"
+import { Mode } from "../../session/mode"
 
 export const TuiCommand = cmd({
   command: "$0 [project]",
@@ -87,6 +88,7 @@ export const TuiCommand = cmd({
             CGO_ENABLED: "0",
             OPENCODE_SERVER: server.url.toString(),
             OPENCODE_APP_INFO: JSON.stringify(app),
+            OPENCODE_MODES: JSON.stringify(await Mode.list()),
           },
           onExit: () => {
             server.stop()

+ 1 - 0
packages/opencode/src/config/config.ts

@@ -70,6 +70,7 @@ export namespace Config {
     .object({
       leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
       app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
+      switch_mode: z.string().optional().default("tab").describe("Switch mode"),
       editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),

+ 1 - 0
packages/opencode/src/server/server.ts

@@ -448,6 +448,7 @@ export namespace Server {
           z.object({
             providerID: z.string(),
             modelID: z.string(),
+            mode: z.string(),
             parts: MessageV2.UserPart.array(),
           }),
         ),

+ 1 - 1
packages/opencode/src/session/index.ts

@@ -284,7 +284,7 @@ export namespace Session {
     sessionID: string
     providerID: string
     modelID: string
-    mode?: string
+    mode: string
     parts: MessageV2.UserPart[]
   }) {
     const l = log.clone().tag("session", input.sessionID)

+ 1 - 0
packages/opencode/src/session/mode.ts

@@ -30,6 +30,7 @@ export namespace Mode {
             write: false,
             edit: false,
             patch: false,
+            bash: false,
           },
         },
       },

+ 10 - 2
packages/tui/cmd/opencode/main.go

@@ -39,6 +39,14 @@ func main() {
 		os.Exit(1)
 	}
 
+	modesStr := os.Getenv("OPENCODE_MODES")
+	var modes []opencode.Mode
+	err = json.Unmarshal([]byte(modesStr), &modes)
+	if err != nil {
+		slog.Error("Failed to unmarshal modes", "error", err)
+		os.Exit(1)
+	}
+
 	httpClient := opencode.NewClient(
 		option.WithBaseURL(url),
 	)
@@ -47,7 +55,7 @@ func main() {
 	logger := slog.New(apiHandler)
 	slog.SetDefault(logger)
 
-	slog.Debug("TUI launched", "app", appInfo)
+	slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
 
 	go func() {
 		err = clipboard.Init()
@@ -60,7 +68,7 @@ func main() {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
-	app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
+	app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt)
 	if err != nil {
 		panic(err)
 	}

+ 87 - 8
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)

+ 7 - 0
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"
@@ -152,6 +153,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Keybindings: parseBindings("<leader>h"),
 			Trigger:     []string{"help"},
 		},
+		{
+			Name:        SwitchModeCommand,
+			Description: "switch mode",
+			Keybindings: parseBindings("tab"),
+			Trigger:     []string{"mode"},
+		},
 		{
 			Name:        EditorOpenCommand,
 			Description: "open editor",

+ 97 - 38
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

+ 15 - 6
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),
 	}
 }

+ 9 - 2
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...")

+ 4 - 4
packages/tui/sdk/.stats.yml

@@ -1,4 +1,4 @@
-configured_endpoints: 21
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-879570c29c56e0a73a0624a84662b7f7c319a3c790c78ec6ac4cf62a7b1a5bd0.yml
-openapi_spec_hash: 2432e2dfed22193a0c6b3dfe0f82ec7d
-config_hash: 53e3aeb355f3b2e0d10985d6d7635a7e
+configured_endpoints: 22
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-37247433660e125f9a79ff173d8007f13192d28a40f4e599e002446a6ed0c128.yml
+openapi_spec_hash: 8095ebe2d88259381a58e7b0c87244c4
+config_hash: 589ec6a935a43a3c49a325ece86cbda2

+ 2 - 0
packages/tui/sdk/api.md

@@ -20,12 +20,14 @@ Response Types:
 
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#LogLevel">LogLevel</a>
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>
 
 Methods:
 
 - <code title="get /app">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /app/init">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /log">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Log">Log</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppLogParams">AppLogParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+- <code title="get /mode">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Modes">Modes</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 
 # Find
 

+ 56 - 0
packages/tui/sdk/app.go

@@ -55,6 +55,14 @@ func (r *AppService) Log(ctx context.Context, body AppLogParams, opts ...option.
 	return
 }
 
+// List all modes
+func (r *AppService) Modes(ctx context.Context, opts ...option.RequestOption) (res *[]Mode, err error) {
+	opts = append(r.Options[:], opts...)
+	path := "mode"
+	err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+	return
+}
+
 type App struct {
 	Git      bool    `json:"git,required"`
 	Hostname string  `json:"hostname,required"`
@@ -149,6 +157,54 @@ func (r LogLevel) IsKnown() bool {
 	return false
 }
 
+type Mode struct {
+	Name   string          `json:"name,required"`
+	Tools  map[string]bool `json:"tools,required"`
+	Model  ModeModel       `json:"model"`
+	Prompt string          `json:"prompt"`
+	JSON   modeJSON        `json:"-"`
+}
+
+// modeJSON contains the JSON metadata for the struct [Mode]
+type modeJSON struct {
+	Name        apijson.Field
+	Tools       apijson.Field
+	Model       apijson.Field
+	Prompt      apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *Mode) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r modeJSON) RawJSON() string {
+	return r.raw
+}
+
+type ModeModel struct {
+	ModelID    string        `json:"modelID,required"`
+	ProviderID string        `json:"providerID,required"`
+	JSON       modeModelJSON `json:"-"`
+}
+
+// modeModelJSON contains the JSON metadata for the struct [ModeModel]
+type modeModelJSON struct {
+	ModelID     apijson.Field
+	ProviderID  apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ModeModel) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r modeModelJSON) RawJSON() string {
+	return r.raw
+}
+
 type AppLogParams struct {
 	// Log level
 	Level param.Field[AppLogParamsLevel] `json:"level,required"`

+ 22 - 0
packages/tui/sdk/app_test.go

@@ -85,3 +85,25 @@ func TestAppLogWithOptionalParams(t *testing.T) {
 		t.Fatalf("err should be nil: %s", err.Error())
 	}
 }
+
+func TestAppModes(t *testing.T) {
+	t.Skip("skipped: tests are disabled for the time being")
+	baseURL := "http://localhost:4010"
+	if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+		baseURL = envURL
+	}
+	if !testutil.CheckTestServer(t, baseURL) {
+		return
+	}
+	client := opencode.NewClient(
+		option.WithBaseURL(baseURL),
+	)
+	_, err := client.App.Modes(context.TODO())
+	if err != nil {
+		var apierr *opencode.Error
+		if errors.As(err, &apierr) {
+			t.Log(string(apierr.DumpRequest(true)))
+		}
+		t.Fatalf("err should be nil: %s", err.Error())
+	}
+}

+ 77 - 1
packages/tui/sdk/config.go

@@ -65,7 +65,8 @@ type Config struct {
 	// Minimum log level to write to log files
 	LogLevel LogLevel `json:"log_level"`
 	// MCP (Model Context Protocol) server configurations
-	Mcp map[string]ConfigMcp `json:"mcp"`
+	Mcp  map[string]ConfigMcp `json:"mcp"`
+	Mode ConfigMode           `json:"mode"`
 	// Model to use in the format of provider/model, eg anthropic/claude-2
 	Model string `json:"model"`
 	// Custom provider configurations and model overrides
@@ -86,6 +87,7 @@ type configJSON struct {
 	Keybinds          apijson.Field
 	LogLevel          apijson.Field
 	Mcp               apijson.Field
+	Mode              apijson.Field
 	Model             apijson.Field
 	Provider          apijson.Field
 	Theme             apijson.Field
@@ -276,6 +278,77 @@ func (r ConfigMcpType) IsKnown() bool {
 	return false
 }
 
+type ConfigMode struct {
+	Build       ConfigModeBuild       `json:"build"`
+	Plan        ConfigModePlan        `json:"plan"`
+	ExtraFields map[string]ConfigMode `json:"-,extras"`
+	JSON        configModeJSON        `json:"-"`
+}
+
+// configModeJSON contains the JSON metadata for the struct [ConfigMode]
+type configModeJSON struct {
+	Build       apijson.Field
+	Plan        apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ConfigMode) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r configModeJSON) RawJSON() string {
+	return r.raw
+}
+
+type ConfigModeBuild struct {
+	Model  string              `json:"model"`
+	Prompt string              `json:"prompt"`
+	Tools  map[string]bool     `json:"tools"`
+	JSON   configModeBuildJSON `json:"-"`
+}
+
+// configModeBuildJSON contains the JSON metadata for the struct [ConfigModeBuild]
+type configModeBuildJSON struct {
+	Model       apijson.Field
+	Prompt      apijson.Field
+	Tools       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ConfigModeBuild) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r configModeBuildJSON) RawJSON() string {
+	return r.raw
+}
+
+type ConfigModePlan struct {
+	Model  string             `json:"model"`
+	Prompt string             `json:"prompt"`
+	Tools  map[string]bool    `json:"tools"`
+	JSON   configModePlanJSON `json:"-"`
+}
+
+// configModePlanJSON contains the JSON metadata for the struct [ConfigModePlan]
+type configModePlanJSON struct {
+	Model       apijson.Field
+	Prompt      apijson.Field
+	Tools       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ConfigModePlan) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r configModePlanJSON) RawJSON() string {
+	return r.raw
+}
+
 type ConfigProvider struct {
 	Models  map[string]ConfigProviderModel `json:"models,required"`
 	ID      string                         `json:"id"`
@@ -460,6 +533,8 @@ type Keybinds struct {
 	SessionShare string `json:"session_share,required"`
 	// Unshare current session
 	SessionUnshare string `json:"session_unshare,required"`
+	// Switch mode
+	SwitchMode string `json:"switch_mode,required"`
 	// List available themes
 	ThemeList string `json:"theme_list,required"`
 	// Toggle tool details
@@ -500,6 +575,7 @@ type keybindsJSON struct {
 	SessionNew           apijson.Field
 	SessionShare         apijson.Field
 	SessionUnshare       apijson.Field
+	SwitchMode           apijson.Field
 	ThemeList            apijson.Field
 	ToolDetails          apijson.Field
 	raw                  string

+ 31 - 22
packages/tui/sdk/session.go

@@ -439,11 +439,12 @@ type AssistantMessagePart struct {
 	Type AssistantMessagePartType `json:"type,required"`
 	ID   string                   `json:"id"`
 	// This field can have the runtime type of [ToolPartState].
-	State interface{}              `json:"state"`
-	Text  string                   `json:"text"`
-	Tool  string                   `json:"tool"`
-	JSON  assistantMessagePartJSON `json:"-"`
-	union AssistantMessagePartUnion
+	State     interface{}              `json:"state"`
+	Synthetic bool                     `json:"synthetic"`
+	Text      string                   `json:"text"`
+	Tool      string                   `json:"tool"`
+	JSON      assistantMessagePartJSON `json:"-"`
+	union     AssistantMessagePartUnion
 }
 
 // assistantMessagePartJSON contains the JSON metadata for the struct
@@ -452,6 +453,7 @@ type assistantMessagePartJSON struct {
 	Type        apijson.Field
 	ID          apijson.Field
 	State       apijson.Field
+	Synthetic   apijson.Field
 	Text        apijson.Field
 	Tool        apijson.Field
 	raw         string
@@ -815,15 +817,17 @@ func (r StepStartPartType) IsKnown() bool {
 }
 
 type TextPart struct {
-	Text string       `json:"text,required"`
-	Type TextPartType `json:"type,required"`
-	JSON textPartJSON `json:"-"`
+	Text      string       `json:"text,required"`
+	Type      TextPartType `json:"type,required"`
+	Synthetic bool         `json:"synthetic"`
+	JSON      textPartJSON `json:"-"`
 }
 
 // textPartJSON contains the JSON metadata for the struct [TextPart]
 type textPartJSON struct {
 	Text        apijson.Field
 	Type        apijson.Field
+	Synthetic   apijson.Field
 	raw         string
 	ExtraFields map[string]apijson.Field
 }
@@ -855,8 +859,9 @@ func (r TextPartType) IsKnown() bool {
 }
 
 type TextPartParam struct {
-	Text param.Field[string]       `json:"text,required"`
-	Type param.Field[TextPartType] `json:"type,required"`
+	Text      param.Field[string]       `json:"text,required"`
+	Type      param.Field[TextPartType] `json:"type,required"`
+	Synthetic param.Field[bool]         `json:"synthetic"`
 }
 
 func (r TextPartParam) MarshalJSON() (data []byte, err error) {
@@ -1311,13 +1316,14 @@ func (r userMessageTimeJSON) RawJSON() string {
 }
 
 type UserMessagePart struct {
-	Type     UserMessagePartType `json:"type,required"`
-	Filename string              `json:"filename"`
-	Mime     string              `json:"mime"`
-	Text     string              `json:"text"`
-	URL      string              `json:"url"`
-	JSON     userMessagePartJSON `json:"-"`
-	union    UserMessagePartUnion
+	Type      UserMessagePartType `json:"type,required"`
+	Filename  string              `json:"filename"`
+	Mime      string              `json:"mime"`
+	Synthetic bool                `json:"synthetic"`
+	Text      string              `json:"text"`
+	URL       string              `json:"url"`
+	JSON      userMessagePartJSON `json:"-"`
+	union     UserMessagePartUnion
 }
 
 // userMessagePartJSON contains the JSON metadata for the struct [UserMessagePart]
@@ -1325,6 +1331,7 @@ type userMessagePartJSON struct {
 	Type        apijson.Field
 	Filename    apijson.Field
 	Mime        apijson.Field
+	Synthetic   apijson.Field
 	Text        apijson.Field
 	URL         apijson.Field
 	raw         string
@@ -1390,11 +1397,12 @@ func (r UserMessagePartType) IsKnown() bool {
 }
 
 type UserMessagePartParam struct {
-	Type     param.Field[UserMessagePartType] `json:"type,required"`
-	Filename param.Field[string]              `json:"filename"`
-	Mime     param.Field[string]              `json:"mime"`
-	Text     param.Field[string]              `json:"text"`
-	URL      param.Field[string]              `json:"url"`
+	Type      param.Field[UserMessagePartType] `json:"type,required"`
+	Filename  param.Field[string]              `json:"filename"`
+	Mime      param.Field[string]              `json:"mime"`
+	Synthetic param.Field[bool]                `json:"synthetic"`
+	Text      param.Field[string]              `json:"text"`
+	URL       param.Field[string]              `json:"url"`
 }
 
 func (r UserMessagePartParam) MarshalJSON() (data []byte, err error) {
@@ -1409,6 +1417,7 @@ type UserMessagePartUnionParam interface {
 }
 
 type SessionChatParams struct {
+	Mode       param.Field[string]                      `json:"mode,required"`
 	ModelID    param.Field[string]                      `json:"modelID,required"`
 	Parts      param.Field[[]UserMessagePartUnionParam] `json:"parts,required"`
 	ProviderID param.Field[string]                      `json:"providerID,required"`

+ 4 - 2
packages/tui/sdk/session_test.go

@@ -117,10 +117,12 @@ func TestSessionChat(t *testing.T) {
 		context.TODO(),
 		"id",
 		opencode.SessionChatParams{
+			Mode:    opencode.F("mode"),
 			ModelID: opencode.F("modelID"),
 			Parts: opencode.F([]opencode.UserMessagePartUnionParam{opencode.TextPartParam{
-				Text: opencode.F("text"),
-				Type: opencode.F(opencode.TextPartTypeText),
+				Text:      opencode.F("text"),
+				Type:      opencode.F(opencode.TextPartTypeText),
+				Synthetic: opencode.F(true),
 			}}),
 			ProviderID: opencode.F("providerID"),
 		},

+ 2 - 2
scripts/stainless

@@ -9,8 +9,8 @@ SERVER_PID=$!
 echo "Waiting for server to start..."
 sleep 3
 
-echo "Fetching OpenAPI spec from http://localhost:4096/doc..."
-curl -s http://localhost:4096/doc > openapi.json
+echo "Fetching OpenAPI spec from http://127.0.0.1:4096/doc..."
+curl -s http://127.0.0.1:4096/doc > openapi.json
 
 echo "Stopping server..."
 kill $SERVER_PID

+ 2 - 0
stainless.yml

@@ -49,10 +49,12 @@ resources:
     models:
       app: App
       logLevel: LogLevel
+      mode: Mode
     methods:
       get: get /app
       init: post /app/init
       log: post /log
+      modes: get /mode
 
   find:
     models: