chore: rework openapi spec and use stainless sdk

This commit is contained in:
adamdottv 2025-06-27 07:46:42 -05:00
parent 226a4a7f36
commit 79bbf90b72
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
28 changed files with 658 additions and 6634 deletions

View file

@ -54,14 +54,7 @@ $ bun run packages/opencode/src/index.ts
#### Development Notes
**API Client Generation**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you need to regenerate the Go client and OpenAPI specification:
```bash
$ cd packages/tui
$ go generate ./pkg/client/
```
This updates the generated Go client code that the TUI uses to communicate with the backend server.
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
### FAQ

View file

@ -7,7 +7,6 @@
- **Typecheck**: `bun run typecheck` (npm run typecheck)
- **Test**: `bun test` (runs all tests)
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
- **API Client Generation**: `cd packages/tui && go generate ./pkg/client/` (after changes to server endpoints)
## Code Style
@ -38,4 +37,4 @@
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
- **API Client**: Go TUI communicates with TypeScript server via generated client. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `cd packages/tui && go generate ./pkg/client/` to update the Go client code and OpenAPI spec.
- **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.

View file

@ -27,7 +27,7 @@ export namespace App {
}),
})
.openapi({
ref: "App.Info",
ref: "App",
})
export type Info = z.infer<typeof Info>

View file

@ -40,7 +40,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.McpLocal",
ref: "McpLocalConfig",
})
export const McpRemote = z
@ -50,7 +50,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.McpRemote",
ref: "McpRemoteConfig",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
@ -124,7 +124,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.Keybinds",
ref: "KeybindsConfig",
})
export const Info = z
.object({
@ -197,7 +197,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.Info",
ref: "Config",
})
export type Info = z.output<typeof Info>

View file

@ -29,7 +29,7 @@ export namespace ModelsDev {
options: z.record(z.any()),
})
.openapi({
ref: "Model.Info",
ref: "Model",
})
export type Model = z.infer<typeof Model>
@ -43,7 +43,7 @@ export namespace ModelsDev {
models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
ref: "Provider",
})
export type Provider = z.infer<typeof Provider>

View file

@ -9,12 +9,10 @@ import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Config } from "../config/config"
const ERRORS = {
@ -70,12 +68,12 @@ export namespace Server {
})
})
.get(
"/openapi",
"/doc",
openAPISpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
version: "0.0.2",
description: "opencode api",
},
openapi: "3.0.0",
@ -122,8 +120,8 @@ export namespace Server {
})
},
)
.post(
"/app_info",
.get(
"/app",
describeRoute({
description: "Get app info",
responses: {
@ -142,26 +140,7 @@ export namespace Server {
},
)
.post(
"/config_get",
describeRoute({
description: "Get config info",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.post(
"/app_initialize",
"/app/init",
describeRoute({
description: "Initialize the app",
responses: {
@ -180,172 +159,27 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/session_initialize",
.get(
"/config",
describeRoute({
description: "Analyze the app and create an AGENTS.md file",
description: "Get config info",
responses: {
200: {
description: "200",
description: "Get config info",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.initialize(body)
return c.json(true)
},
)
.post(
"/path_get",
describeRoute({
description: "Get paths",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(
z.object({
root: z.string(),
data: z.string(),
cwd: z.string(),
config: z.string(),
}),
),
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
const app = App.info()
return c.json({
root: app.path.root,
data: app.path.data,
cwd: app.path.cwd,
config: Global.Path.data,
})
return c.json(await Config.get())
},
)
.post(
"/session_create",
describeRoute({
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.share(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_unshare",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.unshare(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("json").sessionID)
return c.json(messages)
},
)
.post(
"/session_list",
.get(
"/session",
describeRoute({
description: "List all sessions",
responses: {
@ -365,33 +199,28 @@ export namespace Server {
},
)
.post(
"/session_abort",
"/session",
describeRoute({
description: "Abort a session",
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Aborted session",
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(z.boolean()),
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
return c.json(Session.abort(body.sessionID))
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_delete",
.delete(
"/session/:id",
describeRoute({
description: "Delete a session and all its data",
responses: {
@ -406,24 +235,23 @@ export namespace Server {
},
}),
zValidator(
"json",
"param",
z.object({
sessionID: z.string(),
id: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
await Session.remove(c.req.valid("param").id)
return c.json(true)
},
)
.post(
"/session_summarize",
"/session/:id/init",
describeRoute({
description: "Summarize the session",
description: "Analyze the app and create an AGENTS.md file",
responses: {
200: {
description: "Summarize the session",
description: "200",
content: {
"application/json": {
schema: resolver(z.boolean()),
@ -432,27 +260,175 @@ export namespace Server {
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize(body)
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
.post(
"/session_chat",
"/session/:id/abort",
describeRoute({
description: "Chat with a model",
description: "Abort a session",
responses: {
200: {
description: "Chat with a model",
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
return c.json(Session.abort(c.req.valid("param").id))
},
)
.post(
"/session/:id/share",
describeRoute({
description: "Share a session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.share(id)
const session = await Session.get(id)
return c.json(session)
},
)
.delete(
"/session/:id/share",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.unshare(id)
const session = await Session.get(id)
return c.json(session)
},
)
.post(
"/session/:id/summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize({ ...body, sessionID: id })
return c.json(true)
},
)
.get(
"/session/:id/message",
describeRoute({
description: "List messages for a session",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("param").id)
return c.json(messages)
},
)
.post(
"/session/:id/message",
describeRoute({
description: "Create and send a new message to a session",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(Message.Info),
@ -461,23 +437,29 @@ export namespace Server {
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
parts: Message.Part.array(),
parts: Message.MessagePart.array(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
const msg = await Session.chat(body)
const msg = await Session.chat({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/provider_list",
.get(
"/config/providers",
describeRoute({
description: "List all providers",
responses: {
@ -509,8 +491,8 @@ export namespace Server {
})
},
)
.post(
"/file_search",
.get(
"/file",
describeRoute({
description: "Search for files",
responses: {
@ -525,41 +507,22 @@ export namespace Server {
},
}),
zValidator(
"json",
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
const query = c.req.valid("query").query
const app = App.info()
const result = await Ripgrep.files({
cwd: app.path.cwd,
query: body.query,
query,
limit: 10,
})
return c.json(result)
},
)
.post(
"installation_info",
describeRoute({
description: "Get installation info",
responses: {
200: {
description: "Get installation info",
content: {
"application/json": {
schema: resolver(Installation.Info),
},
},
},
},
}),
async (c) => {
return c.json(Installation.info())
},
)
return result
}

View file

@ -55,14 +55,18 @@ export namespace Session {
}),
})
.openapi({
ref: "session.info",
ref: "Session",
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export const ShareInfo = z
.object({
secret: z.string(),
url: z.string(),
})
.openapi({
ref: "SessionShare",
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
@ -273,7 +277,7 @@ export namespace Session {
sessionID: string
providerID: string
modelID: string
parts: Message.Part[]
parts: Message.MessagePart[]
system?: string[]
tools?: Tool.Info[]
}) {
@ -951,7 +955,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
throw new Error("not implemented")
}
function toParts(parts: Message.Part[]): UIMessage["parts"] {
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
const result: UIMessage["parts"] = []
for (const part of parts) {
switch (part.type) {

View file

@ -18,7 +18,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolCall",
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
@ -31,7 +31,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolPartialCall",
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
@ -45,14 +45,14 @@ export namespace Message {
result: z.string(),
})
.openapi({
ref: "Message.ToolInvocation.ToolResult",
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "Message.ToolInvocation",
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
@ -62,7 +62,7 @@ export namespace Message {
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
@ -73,7 +73,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.Reasoning",
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
@ -83,7 +83,7 @@ export namespace Message {
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
@ -96,7 +96,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.SourceUrl",
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
@ -108,7 +108,7 @@ export namespace Message {
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
@ -117,11 +117,11 @@ export namespace Message {
type: z.literal("step-start"),
})
.openapi({
ref: "Message.Part.StepStart",
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const Part = z
export const MessagePart = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
@ -131,15 +131,15 @@ export namespace Message {
StepStartPart,
])
.openapi({
ref: "Message.Part",
ref: "MessagePart",
})
export type Part = z.infer<typeof Part>
export type MessagePart = z.infer<typeof MessagePart>
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(Part),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
@ -189,10 +189,10 @@ export namespace Message {
})
.optional(),
})
.openapi({ ref: "Message.Metadata" }),
.openapi({ ref: "MessageMetadata" }),
})
.openapi({
ref: "Message.Info",
ref: "Message",
})
export type Info = z.infer<typeof Info>
@ -205,7 +205,11 @@ export namespace Message {
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }),
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}

View file

@ -5,7 +5,6 @@
- **Build**: `go build ./cmd/opencode` (builds main binary)
- **Test**: `go test ./...` (runs all tests)
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
- **Generate client**: `go generate ./pkg/client/` (after server endpoint changes)
- **Release build**: Uses `.goreleaser.yml` configuration
## Code Style

View file

@ -9,6 +9,8 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
@ -25,7 +27,7 @@ func main() {
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
var appInfo opencode.App
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
@ -51,7 +53,10 @@ func main() {
slog.Debug("TUI launched", "app", appInfo)
httpClient, err := client.NewClientWithResponses(url)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
@ -73,13 +78,7 @@ func main() {
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
if err != nil {
slog.Error("Failed to create event client", "error", err)
os.Exit(1)
}
evts, err := eventClient.Event(ctx)
evts, err := client.Event(httpClient, url, ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
os.Exit(1)

View file

@ -16,6 +16,8 @@ require (
github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.5
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)
@ -48,6 +50,9 @@ require (
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
@ -68,10 +73,10 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.16
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

View file

@ -191,6 +191,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/sst/opencode-sdk-go v0.1.0-alpha.5 h1:iZjdSHLo6jOMjUbDH5JWi+44v76yNbEktsRqG/Qxrco=
github.com/sst/opencode-sdk-go v0.1.0-alpha.5/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@ -198,6 +200,16 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=

View file

@ -11,35 +11,35 @@ import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
var RootPath string
type App struct {
Info client.AppInfo
Info opencode.App
Version string
StatePath string
Config *client.ConfigInfo
Client *client.ClientWithResponses
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.Message
Commands commands.CommandRegistry
}
type SessionSelectedMsg = *client.SessionInfo
type SessionSelectedMsg = *opencode.Session
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
Provider opencode.Provider
Model opencode.Model
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
@ -51,31 +51,24 @@ type CompletionDialogTriggeredMsg struct {
InitialValue string
}
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
Message opencode.Message
}
func New(
ctx context.Context,
version string,
appInfo client.AppInfo,
httpClient *client.ClientWithResponses,
appInfo opencode.App,
httpClient *opencode.Client,
) (*App, error) {
RootPath = appInfo.Path.Root
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
return nil, err
}
if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode())
}
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
if configInfo.Keybinds.Leader == "" {
configInfo.Keybinds.Leader = "ctrl+x"
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
@ -85,16 +78,16 @@ func New(
config.SaveState(appStatePath, appState)
}
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
if configInfo.Model != "" {
splits := strings.Split(configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
// Load themes from all directories
if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config,
appInfo.Path.Root,
@ -122,8 +115,8 @@ func New(
Config: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Session: &opencode.Session{},
Messages: []opencode.Message{},
Commands: commands.LoadFromConfig(configInfo),
}
@ -132,23 +125,19 @@ func New(
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
if providersResponse != nil && providersResponse.StatusCode() != 200 {
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
return nil
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
@ -159,7 +148,7 @@ func (a *App) InitializeProvider() tea.Cmd {
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providersResponse.JSON200.Providers {
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
@ -171,14 +160,14 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil
}
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.Id == a.State.Provider {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.Id == a.State.Model {
if model.ID == a.State.Model {
currentModel = &model
}
}
@ -189,7 +178,6 @@ func (a *App) InitializeProvider() tea.Cmd {
currentModel = defaultModel
}
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
@ -197,8 +185,8 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
if match, ok := response.JSON200.Default[provider.Id]; ok {
func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
} else {
@ -222,7 +210,7 @@ func (a *App) IsBusy() bool {
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == nil
return lastMessage.Metadata.Time.Completed == 0
}
func (a *App) SaveState() {
@ -245,19 +233,14 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to initialize project", "error", response.StatusCode)
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
}
}()
return tea.Batch(cmds...)
@ -265,48 +248,37 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
}()
return nil
}
func (a *App) MarkProjectInitialized(ctx context.Context) error {
response, err := a.Client.PostAppInitialize(ctx)
_, err := a.Client.App.Init(ctx)
if err != nil {
slog.Error("Failed to mark project as initialized", "error", err)
return err
}
if response != nil && response.StatusCode != 200 {
return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
}
return nil
}
func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
resp, err := a.Client.PostSessionCreateWithResponse(ctx)
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
session, err := a.Client.Session.New(ctx)
if err != nil {
return nil, err
}
if resp != nil && resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
}
session := resp.JSON200
return session, nil
}
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
var cmds []tea.Cmd
if a.Session.Id == "" {
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return toast.NewErrorToast(err.Error())
@ -315,26 +287,18 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{
Type: "text",
Text: text,
})
parts := []client.MessagePart{part}
optimisticMessage := client.MessageInfo{
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: client.User,
Parts: parts,
Metadata: client.MessageMetadata{
SessionID: a.Session.Id,
Time: struct {
Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"`
}{
Created: float32(time.Now().Unix()),
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}},
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
Created: float64(time.Now().Unix()),
},
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
},
}
@ -342,22 +306,21 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
SessionID: a.Session.Id,
Parts: parts,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F([]opencode.MessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
if response != nil && response.StatusCode != 200 {
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
return nil
})
@ -367,83 +330,61 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
SessionID: sessionID,
})
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
// status.Error(err.Error())
return err
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil
}
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
resp, err := a.Client.PostSessionListWithResponse(ctx)
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
response, err := a.Client.Session.List(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
if response == nil {
return []opencode.Session{}, nil
}
if resp.JSON200 == nil {
return []client.SessionInfo{}, nil
}
sessions := *resp.JSON200
sessions := *response
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0
})
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
_, err := a.Client.Session.Delete(ctx, sessionID)
if err != nil {
slog.Error("Failed to delete session", "error", err)
return err
}
if resp.StatusCode() != 200 {
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
if response == nil {
return []opencode.Message{}, nil
}
if resp.JSON200 == nil {
return []client.MessageInfo{}, nil
}
messages := *resp.JSON200
messages := *response
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
resp, err := a.Client.PostProviderListWithResponse(ctx)
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
response, err := a.Client.Config.Providers(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.ProviderInfo{}, nil
if response == nil {
return []opencode.Provider{}, nil
}
providers := *resp.JSON200
providers := *response
return providers.Providers, nil
}

View file

@ -6,7 +6,7 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client"
"github.com/sst/opencode-sdk-go"
)
type ExecuteCommandMsg Command
@ -123,7 +123,7 @@ func parseBindings(bindings ...string) []Keybinding {
return parsedBindings
}
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
func LoadFromConfig(config *opencode.Config) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
@ -269,10 +269,10 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
marshalled, _ := json.Marshal(config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok {
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command

View file

@ -3,9 +3,9 @@ package completions
import (
"context"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client"
)
type filesAndFoldersContextGroup struct {
@ -29,17 +29,14 @@ func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,
})
files, err := cg.app.Client.File.Search(
context.Background(),
opencode.FileSearchParams{Query: opencode.F(query)},
)
if err != nil {
return []string{}, err
}
if response.JSON200 == nil {
return []string{}, nil
}
return *response.JSON200, nil
return *files, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {

View file

@ -12,12 +12,13 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@ -209,7 +210,7 @@ func calculatePadding() int {
}
}
func renderText(message client.MessageInfo, text string, author string) string {
func renderText(message opencode.Message, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := calculatePadding()
@ -223,7 +224,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
if message.Role == client.Assistant {
if message.Role == opencode.MessageRoleAssistant {
markdownWidth = width - padding - 4 - 3
}
minWidth := max(markdownWidth, (width-4)/2)
@ -235,18 +236,18 @@ func renderText(message client.MessageInfo, text string, author string) string {
messageStyle = messageStyle.AlignHorizontal(lipgloss.Right)
}
content := messageStyle.Render(text)
if message.Role == client.Assistant {
if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
}
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
case client.User:
case opencode.MessageRoleUser:
return renderContentBlock(content,
WithAlign(lipgloss.Right),
WithBorderColor(t.Secondary()),
)
case client.Assistant:
case opencode.MessageRoleAssistant:
return renderContentBlock(content,
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
@ -256,15 +257,16 @@ func renderText(message client.MessageInfo, text string, author string) string {
}
func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
toolCall opencode.ToolInvocationPart,
result *string,
metadata client.MessageMetadata_Tool_AdditionalProperties,
metadata opencode.MessageMetadataTool,
showDetails bool,
isLast bool,
contentOnly bool,
messageMetadata opencode.MessageMetadata,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
return ""
}
@ -294,8 +296,8 @@ func renderToolInvocation(
BorderForeground(t.BackgroundPanel()).
BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" {
title := renderToolAction(toolCall.ToolName)
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolAction(toolCall.ToolInvocation.ToolName)
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
@ -316,8 +318,8 @@ func renderToolInvocation(
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
value := *toolCall.Args
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
@ -339,28 +341,35 @@ func renderToolInvocation(
error := ""
finished := result != nil && *result != ""
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
if m, ok := metadata.Get("message"); ok {
style = style.BorderLeftForeground(t.Error())
error = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Render(m.(string))
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
er := messageMetadata.Error.AsUnion()
switch er.(type) {
case nil:
default:
clientError := er.(opencode.UnknownError)
error = clientError.Data.Message
}
if error != "" {
style = style.BorderLeftForeground(t.Error())
error = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Render(error)
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
title := ""
switch toolCall.ToolName {
switch toolCall.ToolInvocation.ToolName {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("READ %s", toolArgs)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
@ -368,8 +377,9 @@ func renderToolInvocation(
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s", relative(filename))
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
diffField := metadata.ExtraFields["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
if layout.Current.Viewport.Width < 80 {
formattedDiff, _ = diff.FormatUnifiedDiff(
@ -406,7 +416,7 @@ func renderToolInvocation(
)
// Add diagnostics at the bottom if they exist
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
@ -418,7 +428,7 @@ func renderToolInvocation(
body = renderFile(filename, content)
// Add diagnostics at the bottom if they exist
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
@ -427,9 +437,10 @@ func renderToolInvocation(
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s", description)
}
if stdout, ok := metadata.Get("stdout"); ok {
stdout := metadata.JSON.ExtraFields["stdout"]
if !stdout.IsNull() {
command := toolArgsMap["command"].(string)
stdout := stdout.(string)
stdout := stdout.Raw()
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
@ -450,12 +461,13 @@ func renderToolInvocation(
case "todowrite":
title = fmt.Sprintf("PLAN")
if to, ok := metadata.Get("todos"); ok && finished {
todos := to.([]any)
for _, todo := range todos {
t := todo.(map[string]any)
content := t["content"].(string)
switch t["status"].(string) {
todos := metadata.JSON.ExtraFields["todos"]
if !todos.IsNull() && finished {
strTodos := todos.Raw()
todos := gjson.Parse(strTodos)
for _, todo := range todos.Array() {
content := todo.Get("content").String()
switch todo.Get("status").String() {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
@ -470,21 +482,22 @@ func renderToolInvocation(
case "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("TASK %s", description)
if summary, ok := metadata.Get("summary"); ok {
toolcalls := summary.([]any)
// toolcalls :=
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall client.MessageToolInvocationToolCall
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageMetadata_Tool_AdditionalProperties
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
@ -494,6 +507,7 @@ func renderToolInvocation(
false,
false,
true,
messageMetadata,
)
steps = append(steps, step)
}
@ -505,7 +519,7 @@ func renderToolInvocation(
}
default:
toolName := renderToolName(toolCall.ToolName)
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
if result == nil {
empty := ""
@ -537,7 +551,7 @@ func renderToolInvocation(
)
}
if body == "" && error == "" {
if body == "" && error == "" && result != nil {
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
@ -716,31 +730,28 @@ type Diagnostic struct {
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata client.MessageMetadata_Tool_AdditionalProperties, filePath string) string {
diagnosticsData, ok := metadata.Get("diagnostics")
if !ok {
func renderDiagnostics(metadata opencode.MessageMetadata, filePath string) string {
diagnosticsData := metadata.JSON.ExtraFields["diagnostics"]
if diagnosticsData.IsNull() {
return ""
}
// diagnosticsData should be a map[string][]Diagnostic
diagnosticsMap, ok := diagnosticsData.(map[string]interface{})
if !ok {
return ""
}
strDiagnosticsData := diagnosticsData.Raw()
diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
fileDiagnostics, ok := diagnosticsMap[filePath]
if !ok {
return ""
}
diagnosticsList, ok := fileDiagnostics.([]interface{})
diagnosticsList, ok := fileDiagnostics.([]any)
if !ok {
return ""
}
var errorDiagnostics []string
for _, diagInterface := range diagnosticsList {
diagMap, ok := diagInterface.(map[string]interface{})
diagMap, ok := diagInterface.(map[string]any)
if !ok {
continue
}

View file

@ -9,13 +9,13 @@ import (
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
type MessagesComponent interface {
@ -83,7 +83,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.tail {
m.viewport.GotoBottom()
}
case client.EventSessionUpdated, client.EventMessageUpdated:
case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
@ -130,90 +130,97 @@ func (m *messagesComponent) renderView() {
lastToolIndex := 0
lastToolIndices := []int{}
for i, p := range message.Parts {
part, _ := p.ValueByDiscriminator()
switch part.(type) {
case client.MessagePartText:
switch p.Type {
case opencode.MessagePartTypeText:
lastToolIndices = append(lastToolIndices, lastToolIndex)
case client.MessagePartToolInvocation:
case opencode.MessagePartTypeToolInvocation:
lastToolIndex = i
}
}
author := ""
switch message.Role {
case client.User:
case opencode.MessageRoleUser:
author = m.app.Info.User
case client.Assistant:
case opencode.MessageRoleAssistant:
author = message.Metadata.Assistant.ModelID
}
for i, p := range message.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
switch part := p.AsUnion().(type) {
// case client.MessagePartStepStart:
// messages = append(messages, "")
case client.MessagePartText:
text := part.(client.MessagePartText)
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(message, text.Text, author)
content = renderText(message, p.Text, author)
m.cache.Set(key, content)
}
if previousBlockType != none {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
if message.Role == client.User {
if message.Role == opencode.MessageRoleUser {
previousBlockType = userTextBlock
} else if message.Role == client.Assistant {
} else if message.Role == opencode.MessageRoleAssistant {
previousBlockType = assistantTextBlock
}
case client.MessagePartToolInvocation:
case opencode.ToolInvocationPart:
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageMetadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId]
}
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
metadata := opencode.MessageMetadataTool{}
toolCallID := part.ToolInvocation.ToolCallID
// var toolCallID string
// var result *string
// switch toolCall := part.ToolInvocation.AsUnion().(type) {
// case opencode.ToolCall:
// toolCallID = toolCall.ToolCallID
// case opencode.ToolPartialCall:
// toolCallID = toolCall.ToolCallID
// case opencode.ToolResult:
// toolCallID = toolCall.ToolCallID
// result = &toolCall.Result
// }
if _, ok := message.Metadata.Tool[toolCallID]; ok {
metadata = message.Metadata.Tool[toolCallID]
}
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
var result *string
if part.ToolInvocation.Result != "" {
result = &part.ToolInvocation.Result
}
if part.ToolInvocation.State == "result" {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
m.showToolDetails,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolInvocation(
toolCall,
part,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
message.Metadata,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolInvocation(
toolCall,
part,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
message.Metadata,
)
}
@ -226,16 +233,17 @@ func (m *messagesComponent) renderView() {
}
error := ""
if message.Metadata.Error != nil {
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
switch errorValue.(type) {
case client.UnknownError:
clientError := errorValue.(client.UnknownError)
error = clientError.Data.Message
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
blocks = append(blocks, error)
previousBlockType = errorBlock
}
switch err := message.Metadata.Error.AsUnion().(type) {
case nil:
default:
clientError := err.(opencode.UnknownError)
error = clientError.Data.Message
}
if error != "" {
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
blocks = append(blocks, error)
previousBlockType = errorBlock
}
}
@ -254,7 +262,7 @@ func (m *messagesComponent) renderView() {
}
func (m *messagesComponent) header() string {
if m.app.Session.Id == "" {
if m.app.Session.ID == "" {
return ""
}
@ -264,8 +272,8 @@ func (m *messagesComponent) header() string {
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}

View file

@ -128,7 +128,7 @@ func (c *commandsComponent) View() string {
if c.showKeybinds {
for _, kb := range cmd.Keybindings {
if kb.RequiresLeader {
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
} else {
keybindStrs = append(keybindStrs, kb.Key)
}

View file

@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
@ -17,7 +18,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
const (
@ -32,8 +32,8 @@ type ModelDialog interface {
type modelDialog struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
availableProviders []opencode.Provider
provider opencode.Provider
width int
height int
hScrollOffset int
@ -69,7 +69,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
m.setupModelsForProvider(m.provider.Id)
m.setupModelsForProvider(m.provider.ID)
return nil
}
@ -90,7 +90,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models()
var selectedModel client.ModelInfo
var selectedModel opencode.Model
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
@ -119,8 +119,8 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m *modelDialog) models() []client.ModelInfo {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
func (m *modelDialog) models() []opencode.Model {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) int {
return strings.Compare(a.Name, b.Name)
})
return models
@ -139,7 +139,7 @@ func (m *modelDialog) switchProvider(offset int) {
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.Id)
m.setupModelsForProvider(m.provider.ID)
}
func (m *modelDialog) View() string {
@ -175,9 +175,9 @@ func (m *modelDialog) setupModelsForProvider(providerId string) {
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId {
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
for i, model := range models {
if model.Id == m.app.Model.Id {
if model.ID == m.app.Model.ID {
m.modelList.SetSelectedIndex(i)
break
}
@ -200,7 +200,7 @@ func NewModelDialog(app *app.App) ModelDialog {
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.Id == app.Provider.Id {
if provider.ID == app.Provider.ID {
currentProvider = provider
hScrollOffset = i
break
@ -220,6 +220,6 @@ func NewModelDialog(app *app.App) ModelDialog {
),
}
dialog.setupModelsForProvider(currentProvider.Id)
dialog.setupModelsForProvider(currentProvider.ID)
return dialog
}

View file

@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
@ -16,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// SessionDialog interface for the session switching dialog
@ -79,7 +79,7 @@ type sessionDialog struct {
width int
height int
modal *modal.Modal
sessions []client.SessionInfo
sessions []opencode.Session
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
@ -122,7 +122,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.Id),
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
@ -193,10 +193,10 @@ func (s *sessionDialog) Close() tea.Cmd {
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
var filteredSessions []client.SessionInfo
var filteredSessions []opencode.Session
var items []sessionItem
for _, sess := range sessions {
if sess.ParentID != nil {
if sess.ParentID != "" {
continue
}
filteredSessions = append(filteredSessions, sess)

View file

@ -48,7 +48,7 @@ func (m statusComponent) logo() string {
Render(open + code + version)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@ -77,7 +77,7 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.Id == "" {
if m.app.Session.ID == "" {
return styles.NewStyle().
Background(t.Background()).
Width(m.width).
@ -94,13 +94,13 @@ func (m statusComponent) View() string {
Render(m.app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
if m.app.Session.ID != "" {
tokens := float64(0)
cost := float64(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
if message.Metadata.Assistant.Cost > 0 {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {

View file

@ -7,7 +7,6 @@ import (
"os"
"github.com/BurntSushi/toml"
"github.com/sst/opencode/pkg/client"
)
type State struct {
@ -22,13 +21,6 @@ func NewState() *State {
}
}
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
if config.Theme == nil {
config.Theme = &state.Theme
}
return config
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveState(filePath string, state *State) error {

View file

@ -12,6 +12,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/completions"
@ -24,7 +25,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
@ -74,7 +74,7 @@ func (a appModel) Init() tea.Cmd {
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized > 0
return dialog.ShowInitDialogMsg{Show: shouldShow}
})
@ -267,31 +267,31 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case client.EventInstallationUpdated:
case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"),
)
case client.EventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
case opencode.EventListResponseEventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
}
return a, toast.NewSuccessToast("Session deleted successfully")
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &msg.Properties.Info
}
case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.ID {
exists := false
optimisticReplaced := false
// First check if this is replacing an optimistic message
if msg.Properties.Info.Role == client.User {
if msg.Properties.Info.Role == opencode.MessageRoleUser {
// Look for optimistic messages to replace
for i, m := range a.app.Messages {
if strings.HasPrefix(m.Id, "optimistic-") && m.Role == client.User {
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.MessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info
exists = true
@ -304,7 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If not replacing optimistic, check for existing message with same ID
if !optimisticReplaced {
for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id {
if m.ID == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info
exists = true
break
@ -316,11 +316,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case client.EventSessionError:
unknownError, err := msg.Properties.Error.AsUnknownError()
if err == nil {
slog.Error("Server error", "name", unknownError.Name, "message", unknownError.Data.Message)
return a, toast.NewErrorToast(unknownError.Data.Message, toast.WithTitle(unknownError.Name))
case opencode.EventListResponseEventSessionError:
switch err := msg.Properties.Error.AsUnion().(type) {
case nil:
case opencode.UnknownError:
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
@ -336,7 +337,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
messages, err := a.app.ListMessages(context.Background(), msg.Id)
messages, err := a.app.ListMessages(context.Background(), msg.ID)
if err != nil {
slog.Error("Failed to list messages", "error", err)
return a, toast.NewErrorToast("Failed to open session")
@ -346,8 +347,8 @@ 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.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
@ -499,42 +500,32 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
})
cmds = append(cmds, cmd)
case commands.SessionNewCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case commands.SessionShareCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
response, err := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
_, err := a.app.Client.Session.Share(context.Background(), a.app.Session.ID)
if err != nil {
slog.Error("Failed to share session", "error", err)
return a, toast.NewErrorToast("Failed to share session")
}
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
}
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
a.app.Cancel(context.Background(), a.app.Session.ID)
return a, nil
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
// TODO: block until compaction is complete
@ -642,8 +633,8 @@ func NewModel(app *app.App) tea.Model {
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if (*app.Config.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Config.Keybinds.Leader))
if app.Config.Keybinds.Leader != "" {
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
leaderBinding = &binding
}

View file

@ -1,4 +0,0 @@
package client
//go:generate bun run ../../../opencode/src/index.ts generate
//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --package=client --generate=types,client,models -o generated-client.go ./gen/openapi.json

View file

@ -6,11 +6,13 @@ import (
"encoding/json"
"net/http"
"strings"
"github.com/sst/opencode-sdk-go"
)
func (c *Client) Event(ctx context.Context) (<-chan any, error) {
func Event(c *opencode.Client, url string, ctx context.Context) (<-chan any, error) {
events := make(chan any)
req, err := http.NewRequestWithContext(ctx, "GET", c.Server+"event", nil)
req, err := http.NewRequestWithContext(ctx, "GET", url+"event", nil)
if err != nil {
return nil, err
}
@ -31,15 +33,12 @@ func (c *Client) Event(ctx context.Context) (<-chan any, error) {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
var event Event
var event opencode.EventListResponse
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
val, err := event.ValueByDiscriminator()
if err != nil {
continue
}
val := event.AsUnion()
select {
case events <- val:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

105
stainless.yml Normal file
View file

@ -0,0 +1,105 @@
# yaml-language-server: $schema=https://app.stainless.com/config.schema.json
organization:
name: opencode
docs: "https://opencode.ai/docs"
contact: "support@sst.dev"
targets:
typescript:
package_name: "@opencode-ai/sdk"
production_repo: "sst/opencode-sdk-js"
publish:
npm: true
skip: false
go:
package_name: opencode
production_repo: sst/opencode-sdk-go
skip: false
python:
project_name: opencode-ai
package_name: opencode_ai
production_repo: sst/opencode-sdk-python
publish:
pypi: true
skip: false
environments:
production: http://localhost:54321
resources:
$shared:
models:
unknownError: UnknownError
providerAuthError: ProviderAuthError
event:
methods:
list: get /event
app:
models:
app: App
methods:
get: get /app
init: post /app/init
file:
methods:
search: get /file
config:
models:
config: Config
keybinds: KeybindsConfig
mcpLocal: McpLocalConfig
mcpRemote: McpRemoteConfig
provider: Provider
model: Model
methods:
get: get /config
providers: get /config/providers
session:
models:
session: Session
message: Message
toolCall: ToolCall
toolPartialCall: ToolPartialCall
toolResult: ToolResult
textPart: TextPart
reasoningPart: ReasoningPart
toolInvocationPart: ToolInvocationPart
sourceUrlPart: SourceUrlPart
filePart: FilePart
stepStartPart: StepStartPart
messagePart: MessagePart
methods:
list: get /session
create: post /session
delete: delete /session/{id}
init: post /session/{id}/init
abort: post /session/{id}/abort
share: post /session/{id}/share
unshare: delete /session/{id}/share
summarize: post /session/{id}/summarize
messages: get /session/{id}/message
chat: post /session/{id}/message
settings:
disable_mock_tests: true
license: Apache-2.0
security:
- {}
readme:
example_requests:
default:
type: request
endpoint: get /event
params: {}
headline:
type: request
endpoint: get /event
params: {}