mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e375bef0d | |||
| 67106a6967 | |||
| b5d690620d | |||
| 9db3ce1d0b | |||
| 1cc55b68ef | |||
| 469f667774 | |||
| 6603d9a9f0 | |||
| 5dc1920a4c | |||
| d3e5f3f3a8 | |||
| ce4cb820f7 |
@@ -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() {},
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.slice(6, -1)
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = await Bun.file(resolvedPath).text()
|
||||
text = text.replace(match, 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({
|
||||
|
||||
@@ -448,6 +448,7 @@ export namespace Server {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
mode: z.string(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
@@ -580,7 +580,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,
|
||||
|
||||
@@ -30,6 +30,7 @@ export namespace Mode {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
bash: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +39,7 @@ type App struct {
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
IntitialMode *string
|
||||
compactCancel context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -64,9 +69,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 +94,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 +148,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 +195,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 +270,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 +402,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 +501,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -24,6 +24,57 @@ When opencode starts up, it looks for a config file in the current directory or
|
||||
|
||||
---
|
||||
|
||||
## Variable substitution
|
||||
|
||||
You can use variable substitution in your config files to reference environment variables and file contents.
|
||||
|
||||
### Environment variables
|
||||
|
||||
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.
|
||||
|
||||
### File contents
|
||||
|
||||
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
|
||||
- Absolute paths (starting with `/` or `~`)
|
||||
|
||||
This is 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
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
||||
@@ -81,12 +132,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`.
|
||||
@@ -123,6 +174,48 @@ You can configure MCP servers you want to use through the `mcp` option.
|
||||
|
||||
---
|
||||
|
||||
### Modes
|
||||
|
||||
You can configure different modes for opencode through the `mode` option. Modes allow you to customize the behavior, tools, and prompts for different use cases.
|
||||
|
||||
opencode comes with two built-in modes: `build` (default with all tools enabled) and `plan` (restricted mode with file modification tools disabled). You can override these built-in modes or create your own custom modes.
|
||||
|
||||
```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": {
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"bash": false
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"prompt": "{file:./prompts/code-review.txt}",
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"bash": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more here](/docs/modes).
|
||||
|
||||
---
|
||||
|
||||
### Disabled providers
|
||||
|
||||
You can disable providers that are loaded automatically through the `disabled_providers` option. This is useful when you want to prevent certain providers from being loaded even if their credentials are available.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: Modes
|
||||
description: Configure different modes for different use cases.
|
||||
---
|
||||
|
||||
Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases. You can switch between modes during a session or configure them in your config file.
|
||||
|
||||
## Built-in modes
|
||||
|
||||
opencode comes with two built-in modes:
|
||||
|
||||
### Build mode (default)
|
||||
|
||||
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 mode
|
||||
|
||||
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 modes
|
||||
|
||||
You can switch between modes during a session using the `tab` key (or your configured `switch_mode` keybind).
|
||||
|
||||
## Configuring modes
|
||||
|
||||
You can customize modes in your `opencode.json` config file:
|
||||
|
||||
```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
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"prompt": "{file:./prompts/code-review.txt}",
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"bash": false,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mode configuration options
|
||||
|
||||
Each mode can be configured with the following options:
|
||||
|
||||
### `model`
|
||||
|
||||
Override the default model for this mode. Useful for using different models optimized for different tasks (e.g., a faster model for planning, a more capable model for implementation).
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": {
|
||||
"plan": {
|
||||
"model": "anthropic/claude-haiku-4-20250514"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `prompt`
|
||||
|
||||
Specify a custom system prompt file for this mode. The prompt file should contain instructions specific to the mode's purpose.
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": {
|
||||
"review": {
|
||||
"prompt": "{file:./prompts/code-review.txt}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `tools`
|
||||
|
||||
Control which tools are available in this mode. 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating 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"
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available tools
|
||||
|
||||
The following tools can be controlled in mode configurations:
|
||||
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user