Compare commits

...

20 Commits

Author SHA1 Message Date
Dax Raad 49de703ba1 config: escape file: string content 2025-07-10 15:38:58 -04:00
Dax Raad 22988894c8 ci: slow down stats 2025-07-10 15:31:06 -04:00
adamdotdevin 34b1754f25 docs: clipboard requirements on linux 2025-07-10 13:12:37 -05:00
adamdotdevin 54fe3504ba feat(tui): accent editor border on leader key 2025-07-10 12:57:22 -05:00
Jay V d2c862e32d docs: edit local models 2025-07-10 13:49:24 -04:00
Jay V afc53afb35 docs: edit mode 2025-07-10 13:29:37 -04:00
Gabriel Garrett b56e49c5dc Adds real example in docs of how to configure custom provider (#840) 2025-07-10 13:29:30 -04:00
Aiden Cline 8b2a909e1f fix: encode & decode file paths (#843) 2025-07-10 11:19:54 -05:00
Jay V e9c954d45e docs: add modes to sidebar 2025-07-10 12:07:44 -04:00
Jay V 6f449d13af docs: add modes to sidebar 2025-07-10 12:07:18 -04:00
Dax Raad 6e375bef0d docs: modes 2025-07-10 11:53:28 -04:00
Dax Raad 67106a6967 docs: add config variable docs 2025-07-10 11:48:55 -04:00
Dax Raad b5d690620d support env and file pointers in config 2025-07-10 11:45:31 -04:00
Dax Raad 9db3ce1d0b opencode run respects mode 2025-07-10 11:28:28 -04:00
Dax Raad 1cc55b68ef wip: scrap 2025-07-10 11:25:37 -04:00
Dax Raad 469f667774 set max output token limit to 32_000 2025-07-10 11:25:37 -04:00
adamdottv 6603d9a9f0 feat: --mode flag passed to tui 2025-07-10 10:19:25 -05:00
adamdottv 5dc1920a4c feat: mode flag in cli run command 2025-07-10 10:13:15 -05:00
adamdottv d3e5f3f3a8 feat(tui): add token and cost info to session header 2025-07-10 10:06:51 -05:00
adamdottv ce4cb820f7 feat(tui): modes 2025-07-10 10:06:51 -05:00
33 changed files with 1003 additions and 207 deletions
@@ -3,6 +3,7 @@ import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
@@ -12,6 +13,7 @@ export const DebugCommand = cmd({
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(ScrapCommand)
.command(SnapshotCommand)
.command({
command: "wait",
@@ -0,0 +1,7 @@
import { cmd } from "../cmd"
export const ScrapCommand = cmd({
command: "scrap",
builder: (yargs) => yargs,
async handler() {},
})
+14 -2
View File
@@ -8,6 +8,7 @@ import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
import { Mode } from "../../session/mode"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -52,6 +53,10 @@ export const RunCommand = cmd({
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("mode", {
type: "string",
describe: "mode to use",
})
},
handler: async (args) => {
let message = args.message.join(" ")
@@ -139,10 +144,17 @@ export const RunCommand = cmd({
UI.error(err)
})
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
...(mode.model
? mode.model
: {
providerID,
modelID,
}),
mode: mode.name,
parts: [
{
type: "text",
+7
View File
@@ -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]",
@@ -30,6 +31,10 @@ export const TuiCommand = cmd({
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("mode", {
type: "string",
describe: "mode to use",
}),
handler: async (args) => {
while (true) {
@@ -77,6 +82,7 @@ export const TuiCommand = cmd({
...cmd,
...(args.model ? ["--model", args.model] : []),
...(args.prompt ? ["--prompt", args.prompt] : []),
...(args.mode ? ["--mode", args.mode] : []),
],
cwd,
stdout: "inherit",
@@ -87,6 +93,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()
+29 -7
View File
@@ -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"),
@@ -194,19 +195,40 @@ export namespace Config {
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
async function load(configPath: string) {
let text = await Bun.file(configPath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: configPath }, { cause: err })
})
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
if (fileMatches) {
const configDir = path.dirname(configPath)
for (const match of fileMatches) {
const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = await Bun.file(resolvedPath).text()
text = text.replace(match, JSON.stringify(fileContent))
}
}
let data: any
try {
data = JSON.parse(text)
} catch (err) {
throw new JsonError({ path: configPath }, { cause: err as Error })
}
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
+1
View File
@@ -448,6 +448,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
mode: z.string(),
parts: MessageV2.UserPart.array(),
}),
),
+9 -7
View File
@@ -42,6 +42,8 @@ import { ReadTool } from "../tool/read"
export namespace Session {
const log = Log.create({ service: "session" })
const OUTPUT_TOKEN_MAX = 32_000
export const Info = z
.object({
id: Identifier.schema("session"),
@@ -319,15 +321,13 @@ export namespace Session {
}
const previous = msgs.at(-1) as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX)
// auto summarize if too long
if (previous) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (
model.info.limit.context &&
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
) {
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
@@ -350,7 +350,9 @@ export namespace Session {
switch (url.protocol) {
case "file:":
// have to normalize, symbol search returns absolute paths
const relativePath = url.pathname.replace(app.path.cwd, ".")
// Decode the pathname since URL constructor doesn't automatically decode it
const pathname = decodeURIComponent(url.pathname)
const relativePath = pathname.replace(app.path.cwd, ".")
const filePath = path.join(app.path.cwd, relativePath)
if (part.mime === "text/plain") {
@@ -414,7 +416,7 @@ export namespace Session {
return [
{
type: "text",
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
text: `Called the Read tool with the following input: {\"filePath\":\"${pathname}\"}`,
synthetic: true,
},
{
@@ -580,7 +582,7 @@ export namespace Session {
const result = streamText({
onError() {},
maxRetries: 10,
maxOutputTokens: input.modelID.includes("grok-4") ? undefined : Math.max(0, model.info.limit.output) || undefined,
maxOutputTokens: outputLimit,
abortSignal: abort.signal,
stopWhen: stepCountIs(1000),
providerOptions: model.info.options,
+1
View File
@@ -30,6 +30,7 @@ export namespace Mode {
write: false,
edit: false,
patch: false,
bash: false,
},
},
},
+11 -2
View File
@@ -27,6 +27,7 @@ func main() {
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
var mode *string = flag.String("mode", "", "mode to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
@@ -39,6 +40,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 +56,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 +69,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, mode)
if err != nil {
panic(err)
}
+112 -26
View File
@@ -22,20 +22,26 @@ import (
)
type App struct {
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
compactCancel context.CancelFunc
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
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
type SessionSelectedMsg = *opencode.Session
@@ -64,9 +70,11 @@ func New(
ctx context.Context,
version string,
appInfo opencode.App,
modes []opencode.Mode,
httpClient *opencode.Client,
model *string,
prompt *string,
initialModel *string,
initialPrompt *string,
initialMode *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
@@ -87,14 +95,36 @@ 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
}
if initialMode != nil && *initialMode != "" {
modeName = *initialMode
}
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,16 +149,20 @@ 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),
InitialModel: model,
InitialPrompt: prompt,
InitialModel: initialModel,
InitialPrompt: initialPrompt,
IntitialMode: initialMode,
}
return app, nil
@@ -162,6 +196,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 +271,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 +403,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 +502,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)
@@ -79,7 +79,9 @@ func initialize() error {
}
if selectedTool < 0 {
slog.Warn("No clipboard utility found on system. Copy/paste functionality will be disabled.")
slog.Warn(
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
)
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
For X11 systems:
@@ -138,7 +140,8 @@ func readText(tool struct {
// xclip returns error when clipboard doesn't contain requested type
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
targets, _ := checkCmd.Output()
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
if bytes.Contains(targets, []byte("image/png")) &&
!bytes.Contains(targets, []byte("UTF8_STRING")) {
return nil, errUnavailable
}
}
@@ -168,7 +171,8 @@ func readImage(tool struct {
}
// Verify it's PNG data
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
if len(out) < 8 ||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
return nil, errUnavailable
}
@@ -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",
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -180,7 +181,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
Filename: filePath,
MediaType: mediaType,
}
@@ -241,13 +242,17 @@ func (m *editorComponent) Content(width int) string {
prompt,
m.textarea.View(),
)
borderForeground := t.Border()
if m.app.IsLeaderSequence {
borderForeground = t.Accent()
}
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(width).
PaddingTop(1).
PaddingBottom(1).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.Border()).
BorderForeground(borderForeground).
BorderBackground(t.Background()).
BorderLeft(true).
BorderRight(true).
@@ -1,6 +1,7 @@
package chat
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
@@ -371,11 +372,65 @@ func (m *messagesComponent) header(width int) string {
headerLines,
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
)
share := ""
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
share = muted(m.app.Session.Share.URL + " /unshare")
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
share = base("/share") + muted(" to create a shareable link")
}
sessionInfo := ""
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.Background()).
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
background := t.Background()
share = layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
Width: width - 6,
},
layout.FlexItem{
View: share,
},
layout.FlexItem{
View: sessionInfo,
},
)
headerLines = append(headerLines, share)
header := strings.Join(headerLines, "\n")
header = styles.NewStyle().
@@ -393,6 +448,50 @@ func (m *messagesComponent) header(width int) string {
return "\n" + header + "\n"
}
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 {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
percentage := (float64(tokens) / float64(contextWindow)) * 100
if isSubscriptionModel {
return fmt.Sprintf(
"%s/%d%%",
formattedTokens,
int(percentage),
)
}
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"%s/%d%% (%s)",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m *messagesComponent) View(width, height int) string {
t := theme.CurrentTheme()
if m.rendering {
@@ -1,14 +1,14 @@
package status
import (
"fmt"
"os"
"strings"
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,45 +55,6 @@ func (m statusComponent) logo() string {
Render(open + code + version)
}
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 {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
percentage := (float64(tokens) / float64(contextWindow)) * 100
if isSubscriptionModel {
return fmt.Sprintf(
"Context: %s (%d%%)",
formattedTokens,
int(percentage),
)
}
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"Context: %s (%d%%), Cost: %s",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
logo := m.logo()
@@ -104,50 +65,68 @@ 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))
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()
}
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
command := m.app.Commands[commands.SwitchModeCommand]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = m.app.Config.Keybinds.Leader + " " + kb.Key
}
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
View File
@@ -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),
}
}
+30 -24
View File
@@ -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"
@@ -69,17 +70,17 @@ type appModel struct {
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
// isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
}
func (a appModel) Init() tea.Cmd {
@@ -183,9 +184,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 2. Check for commands that require leader
if a.isLeaderSequence {
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
a.isLeaderSequence = false
if a.app.IsLeaderSequence {
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
a.app.IsLeaderSequence = false
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
@@ -260,21 +261,21 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 5. Check for leader key activation
if a.leaderBinding != nil &&
!a.isLeaderSequence &&
!a.app.IsLeaderSequence &&
key.Matches(msg, *a.leaderBinding) {
a.isLeaderSequence = true
a.app.IsLeaderSequence = true
return a, nil
}
// 6 Handle input clear command
inputClearCommand := a.app.Commands[commands.InputClearCommand]
if inputClearCommand.Matches(msg, a.isLeaderSequence) && a.editor.Length() > 0 {
if inputClearCommand.Matches(msg, a.app.IsLeaderSequence) && a.editor.Length() > 0 {
return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
}
// 7. Handle interrupt key debounce for session interrupt
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
switch a.interruptKeyState {
case InterruptKeyIdle:
// First interrupt key press - start debounce timer
@@ -293,7 +294,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 8. Handle exit key debounce for app exit when using non-leader command
exitCommand := a.app.Commands[commands.AppExitCommand]
if exitCommand.Matches(msg, a.isLeaderSequence) {
if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
switch a.exitKeyState {
case ExitKeyIdle:
// First exit key press - start debounce timer
@@ -311,10 +312,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
if len(matches) > 0 {
// Skip interrupt key if we're in debounce mode and app is busy
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
return a, nil
}
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
@@ -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...")
@@ -1060,7 +1067,6 @@ func NewModel(app *app.App) tea.Model {
fileProvider: fileProvider,
symbolsProvider: symbolsProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -62,8 +62,9 @@ export default defineConfig({
sidebar: [
"docs",
"docs/cli",
"docs/rules",
"docs/share",
"docs/modes",
"docs/rules",
"docs/config",
"docs/models",
"docs/themes",
@@ -129,3 +129,4 @@ The opencode CLI takes the following flags.
| `--print-logs` | | Print logs to stderr |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--mode` | | Mode to use |
+97 -12
View File
@@ -3,12 +3,9 @@ title: Config
description: Using the opencode JSON config.
---
You can configure opencode using a JSON config file that can be placed in:
You can configure opencode using a JSON config file.
- Globally under `~/.config/opencode/config.json`.
- Your project root under `opencode.json`. This is safe to be checked into Git and uses the same schema as the global one.
```json
```json title="opencode config"
{
"$schema": "https://opencode.ai/config.json",
"theme": "opencode",
@@ -18,10 +15,24 @@ You can configure opencode using a JSON config file that can be placed in:
}
```
In most cases, you'll want to use the global config for things like themes, providers, or keybinds. Having a config per project is useful if you are using different providers for your company.
This can be used to configure opencode globally or for a specific project.
---
### Global
Place your global opencode config in `~/.config/opencode/config.json`. You'll want to use the global config for things like themes, providers, or keybinds.
---
### Per project
You can also add a `opencode.json` in your project. This is useful for configuring providers or modes specific to your project.
When opencode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory.
This is also safe to be checked into Git and uses the same schema as the global one.
---
## Schema
@@ -32,6 +43,25 @@ Your editor should be able to validate and autocomplete based on the schema.
---
### Modes
opencode comes with two built-in modes: _build_, the default with all tools enabled. And _plan_, restricted mode with file modification tools disabled. You can override these built-in modes or define your own custom modes with the `mode` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": { },
"plan": { },
"my-custom-mode": { }
}
}
```
[Learn more here](/docs/modes).
---
### Models
You can configure the providers and models you want to use in your opencode config through the `provider` and `model` options.
@@ -44,7 +74,7 @@ You can configure the providers and models you want to use in your opencode conf
}
```
[Learn more here](/docs/models).
You can also configure [local models](/docs/models#local). [Learn more](/docs/models).
---
@@ -81,12 +111,12 @@ You can configure the minimum log level through the `log_level` option.
With the following options:
| Level | Description |
| ----- | ----------- |
| Level | Description |
| ------- | ---------------------------------------- |
| `DEBUG` | All messages including debug information |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
The **default** log level is `INFO`. If you are running opencode locally in
development mode it's set to `DEBUG`.
@@ -139,3 +169,58 @@ The `disabled_providers` option accepts an array of provider IDs. When a provide
- It won't be loaded even if environment variables are set
- It won't be loaded even if API keys are configured through `opencode auth login`
- The provider's models won't appear in the model selection list
---
## Variables
You can use variable substitution in your config files to reference environment variables and file contents.
---
### Env vars
Use `{env:VARIABLE_NAME}` to substitute environment variables:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:OPENCODE_MODEL}",
"provider": {
"anthropic": {
"api_key": "{env:ANTHROPIC_API_KEY}"
}
}
}
```
If the environment variable is not set, it will be replaced with an empty string.
---
### Files
Use `{file:path/to/file}` to substitute the contents of a file:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["{file:./custom-instructions.md}"],
"provider": {
"openai": {
"api_key": "{file:~/.secrets/openai-key}"
}
}
}
```
File paths can be:
- Relative to the config file directory
- Or absolute paths starting with `/` or `~`
These are useful for:
- Keeping sensitive data like API keys in separate files.
- Including large instruction files without cluttering your config.
- Sharing common configuration snippets across multiple config files.
+52 -1
View File
@@ -11,10 +11,14 @@ opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.
You can configure providers in your opencode config under the `provider` section.
---
### Defaults
Most popular providers are preloaded by default. If you've added the credentials for a provider through `opencode auth login`, they'll be available when you start opencode.
---
### Custom
You can add custom providers by specifying the npm package for the provider and the models you want to use.
@@ -37,9 +41,44 @@ You can add custom providers by specifying the npm package for the provider and
}
```
---
### Local
To configure a local model, specify the npm package to use and the `baseURL`.
You can configure local model like ones served through LM Studio or Ollama. To
do so, you'll need to specify a couple of things.
Here's an example of configuring a local model from LM Studio:
```json title="opencode.json" {4-15}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lmstudio": {
"npm": "@ai-sdk/openai-compatible",
"name": "LM Studio (local)",
"options": {
"baseURL": "http://127.0.0.1:1234/v1"
},
"models": {
"google/gemma-3n-e4b": {
"name": "Gemma 3n-e4b (local)"
}
}
}
}
}
```
In this example:
- `lmstudio` is the custom provider ID. We'll use this later.
- `npm` specifies the package to use for this provider. Here, `@ai-sdk/openai-compatible` is used for any OpenAI-compatible API.
- `name` is the display name for the provider in the UI.
- `options.baseURL` is the endpoint for the local server.
- `models` is a map of model IDs to their configurations. The model name will be displayed in the model selection list.
Similarly, to configure a local model from Ollama:
```json title="opencode.json" {5,7}
{
@@ -58,6 +97,18 @@ To configure a local model, specify the npm package to use and the `baseURL`.
}
```
To set one of these as the default model, you can set the `model` key at the
root.
```json title="opencode.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"model": "lmstudio/google/gemma-3n-e4b"
}
```
Here the full ID is `provider_id/model_id`, where `provider_id` is the key in the `provider` list we set above and `model_id` is the key from the `provider.models` list.
---
## Select a model
@@ -0,0 +1,199 @@
---
title: Modes
description: Different modes for different use cases.
---
Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases.
It comes with two built-in modes: **build** and **plan**. You can customize
these or configure your own through the opencode config.
:::tip
Use the plan mode to analyze code and review suggestions without making any code
changes.
:::
You can switch between modes during a session or configure them in your config file.
---
## Built-in
opencode comes with two built-in modes.
---
### Build
Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands.
---
### Plan
A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default:
- `write` - Cannot create new files
- `edit` - Cannot modify existing files
- `patch` - Cannot apply patches
- `bash` - Cannot execute shell commands
This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase.
---
## Switching
You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind.
---
## Configure
You can customize the built-in modes or create your own in the opencode [config](/docs/config).
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-20250514",
"prompt": "{file:./prompts/build.txt}",
"tools": {
"write": true,
"edit": true,
"bash": true
}
},
"plan": {
"model": "anthropic/claude-haiku-4-20250514",
"tools": {
"write": false,
"edit": false,
"bash": false
}
}
}
}
```
Let's look at these options in detail.
---
### Model
Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation.
```json title="opencode.json"
{
"mode": {
"plan": {
"model": "anthropic/claude-haiku-4-20250514"
}
}
}
```
---
### Prompt
Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose.
```json title="opencode.json"
{
"mode": {
"review": {
"prompt": "{file:./prompts/code-review.txt}"
}
}
}
```
This path is relative to where the config file is located. So this works for
both the global opencode config and the project specific config.
---
### Tools
Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`.
```json
{
"mode": {
"readonly": {
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
If no tools are specified, all tools are enabled by default.
---
#### Available tools
Here are all the tools can be controlled through the mode config.
| Tool | Description |
| ----------- | ----------------------- |
| `bash` | Execute shell commands |
| `edit` | Modify existing files |
| `write` | Create new files |
| `read` | Read file contents |
| `grep` | Search file contents |
| `glob` | Find files by pattern |
| `list` | List directory contents |
| `patch` | Apply patches to files |
| `todowrite` | Manage todo lists |
| `todoread` | Read todo lists |
| `webfetch` | Fetch web content |
---
## Custom modes
You can create your own custom modes by adding them to the `mode` configuration. For example, a documentation mode that focuses on reading and analysis.
```json title="opencode.json" {4-14}
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"docs": {
"prompt": "{file:./prompts/documentation.txt}",
"tools": {
"write": true,
"edit": true,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
---
### Use cases
Here are some common use cases for different modes.
- **Build mode**: Full development work with all tools enabled
- **Plan mode**: Analysis and planning without making changes
- **Review mode**: Code review with read-only access plus documentation tools
- **Debug mode**: Focused on investigation with bash and read tools enabled
- **Docs mode**: Documentation writing with file operations but no system commands
You might also find different models are good for different use cases.
@@ -43,17 +43,17 @@ This directory contains:
If you're experiencing issues with opencode:
1. **Report issues on GitHub**
The best way to report bugs or request features is through our GitHub repository:
[**github.com/sst/opencode/issues**](https://github.com/sst/opencode/issues)
Before creating a new issue, search existing issues to see if your problem has already been reported.
2. **Join our Discord**
For real-time help and community discussion, join our Discord server:
[**opencode.ai/discord**](https://opencode.ai/discord)
---
@@ -85,3 +85,34 @@ Here are some common issues and how to resolve them.
1. Check that you've authenticated with the provider
2. Verify the model name in your config is correct
3. Some models may require specific access or subscriptions
---
### Copy/paste not working on Linux
Linux users need to have one of the following clipboard utilities installed for copy/paste functionality to work:
**For X11 systems:**
```bash
apt install -y xclip
# or
apt install -y xsel
```
**For Wayland systems:**
```bash
apt install -y wl-clipboard
```
**For headless environments:**
```bash
apt install -y xvfb
# and run:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0
```
opencode will automatically detect and use the first available clipboard tool in order of preference: `xclip`, `xsel`, then `wl-clipboard`.
+2 -2
View File
@@ -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
+1
View File
@@ -60,6 +60,7 @@ async function fetchReleases(): Promise<Release[]> {
if (batch.length < per) break
page++
await new Promise((resolve) => setTimeout(resolve, 1000))
}
return releases
+2
View File
@@ -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: