Compare commits

...

14 Commits

Author SHA1 Message Date
Dax Raad
3fe163416d autoupgrade 2025-06-17 01:05:05 -04:00
Dax
d054f88130 Improve upgrade command with installation method detection (#158) 2025-06-17 00:07:17 -04:00
Jay
b929b4f4b9 docs: Update README.md 2025-06-16 21:01:38 -04:00
Jay V
4c0c83b02d docs: readme 2025-06-16 20:10:19 -04:00
adamdottv
d6d45bdc63 feat: share and init commands 2025-06-16 15:58:52 -05:00
Dax Raad
13a83721b0 ci: fixed ci issue 2025-06-16 16:58:25 -04:00
Dax Raad
f0edffbae9 docs: readme 2025-06-16 16:53:43 -04:00
Dax Raad
8131bee49a ignore: logs 2025-06-16 16:02:45 -04:00
Dax Raad
b5f44ae13f docs: update readme 2025-06-16 15:42:35 -04:00
Miles Till
0d23f2a7fd fix: incorrect lipgloss version (#131) 2025-06-16 14:35:46 -05:00
Dax Raad
ac096d84ad remove windows builds 2025-06-16 15:11:14 -04:00
Dax Raad
fcaf0e6dbf opencode auth login: validation on provider id and better error messages 2025-06-16 15:09:49 -04:00
Dax Raad
19e259d90d docs: readme 2025-06-16 15:04:32 -04:00
Dax Raad
2c9fd1e776 BREAKING CHANGE: the config structure has changed, custom providers have an npm field now to specify which npm package to load. see examples in README.md 2025-06-16 15:02:25 -04:00
27 changed files with 375 additions and 201 deletions

View File

@@ -1,8 +1,24 @@
[![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode)
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
</picture>
</a>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
---
AI coding agent, built for the terminal.
⚠️ **Note:** version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚
**Note:** Version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025.
[![opencode Terminal UI](screenshot.png)](https://opencode.ai)
### Installation
@@ -16,13 +32,13 @@ brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
> **Note:** Remove previous versions < 0.1.x first if installed
> **Note:** Remove versions older than 0.1.x before installing
### Providers
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost-effective way to use this tool.
The recommended approach is to sign up for Claude Pro or Max, run `opencode auth login`, and select Anthropic. It's the most cost-effective way to use opencode.
Additionally, opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
```bash
$ opencode auth login
@@ -41,13 +57,13 @@ $ opencode auth login
```
The models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
If there are additional providers you want to use you can submit a PR to the [models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below.
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it will be loaded.
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it'll be loaded.
```json title="opencode.json"
{
@@ -78,14 +94,14 @@ Project configuration is optional. You can place an `opencode.json` file in the
#### Providers
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Use the npm package name as the key in your config.
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@ai-sdk/openai-compatible": {
"name": "ollama",
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
@@ -101,14 +117,14 @@ You can use opencode with any provider listed at [here](https://ai-sdk.dev/provi
### Contributing
To run opencode locally you need
To run opencode locally you need.
- bun
- golang 1.24.x
- Bun
- Golang 1.24.x
To run
To run.
```
```bash
$ bun install
$ cd packages/opencode
$ bun run src/index.ts
@@ -116,15 +132,16 @@ $ bun run src/index.ts
### FAQ
#### How do I use this with OpenRouter
#### How do I use this with OpenRouter?
OpenRouter is not yet in the models.dev database, but you can configure it manually.
OpenRouter is not in the Models.dev database yet, but you can configure it manually.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@openrouter/ai-sdk-provider": {
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@@ -139,15 +156,23 @@ OpenRouter is not yet in the models.dev database, but you can configure it manua
}
```
#### How is this different than claude code?
#### How is this different than Claude Code?
It is very similar to claude code in terms of capability - here are the key differences:
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although anthropic is recommended opencode can be used with openai, google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- TUI focus - opencode is built by neovim users and the creators of https://terminal.shop - we are going to push the limits of what's possible in the terminal
- client/server architecture - this means the tui frontend is just the first of many. For example, opencode can run on your computer and you can drive it remotely from a mobile app
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### Windows Support
#### What about Windows support?
There are some minor problems blocking opencode from working on windows. We will fix them soon - would need to use wsl for now.
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)

View File

@@ -31,17 +31,6 @@ export const api = new sst.cloudflare.Worker("Api", {
},
})
// new sst.cloudflare.StaticSite("Web", {
// path: "packages/web",
// domain,
// environment: {
// VITE_API_URL: api.url,
// },
// build: {
// command: "bun run build",
// output: "dist",
// },
// })
new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",

View File

@@ -1,16 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@ai-sdk/openai-compatible": {
"name": "ollama",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"qwen3": {},
"deepseek-r1": {},
"llama2": {}
}
}
}
"mcp": {},
"provider": {}
}

View File

@@ -21,6 +21,9 @@
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {

View File

@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
["windows", "x64"],
// ["windows", "x64"],
]
await $`rm -rf dist`

View File

@@ -1,5 +1,4 @@
import { generatePKCE } from "@openauthjs/openauth/pkce"
import fs from "fs/promises"
import { Auth } from "./index"
export namespace AuthAnthropic {

View File

@@ -43,6 +43,7 @@ export namespace BunProc {
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))

View File

@@ -5,7 +5,7 @@ import * as prompts from "@clack/prompts"
import open from "open"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sort, sortBy, values } from "remeda"
import { map, pipe, sortBy, values } from "remeda"
export const AuthCommand = cmd({
command: "auth",
@@ -16,7 +16,7 @@ export const AuthCommand = cmd({
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler(args) {},
async handler() {},
})
export const AuthListCommand = cmd({
@@ -78,9 +78,16 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider - must match @ai-sdk/<provider>",
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
@@ -115,7 +122,9 @@ export const AuthLoginCommand = cmd({
try {
await open(url)
} catch (e) {
prompts.log.error("Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:")
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)

View File

@@ -1,113 +1,8 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import { VERSION } from "../version"
import path from "path"
import fs from "fs/promises"
import os from "os"
import * as prompts from "@clack/prompts"
import { Global } from "../../global"
const API = "https://api.github.com/repos/sst/opencode"
interface Release {
tag_name: string
name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
function asset(): string {
const platform = os.platform()
const arch = os.arch()
if (platform === "darwin") {
return arch === "arm64"
? "opencode-darwin-arm64.zip"
: "opencode-darwin-x64.zip"
}
if (platform === "linux") {
return arch === "arm64"
? "opencode-linux-arm64.zip"
: "opencode-linux-x64.zip"
}
if (platform === "win32") {
return "opencode-windows-x64.zip"
}
throw new Error(`Unsupported platform: ${platform}-${arch}`)
}
function compare(current: string, latest: string): number {
const a = current.replace(/^v/, "")
const b = latest.replace(/^v/, "")
const aParts = a.split(".").map(Number)
const bParts = b.split(".").map(Number)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0
const bPart = bParts[i] || 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
async function latest(): Promise<Release> {
const response = await fetch(`${API}/releases/latest`)
if (!response.ok) {
throw new Error(`Failed to fetch latest release: ${response.statusText}`)
}
return response.json()
}
async function specific(version: string): Promise<Release> {
const tag = version.startsWith("v") ? version : `v${version}`
const response = await fetch(`${API}/releases/tags/${tag}`)
if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
}
return response.json()
}
async function download(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`)
}
const buffer = await response.arrayBuffer()
const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
await Bun.write(temp, buffer)
const extractDir = path.join(
Global.Path.cache,
`opencode-extract-${Date.now()}`,
)
await fs.mkdir(extractDir, { recursive: true })
const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
stdout: "pipe",
stderr: "pipe",
})
const result = await proc.exited
if (result !== 0) {
throw new Error("Failed to extract update")
}
await fs.unlink(temp)
const binary = path.join(extractDir, "opencode")
await fs.chmod(binary, 0o755)
return binary
}
import { Installation } from "../../installation"
export const UpgradeCommand = {
command: "upgrade [target]",
@@ -123,14 +18,35 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
const method = await Installation.method()
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.outro("Done")
return
}
const target = args.target ?? (await Installation.latest())
prompts.log.info(`From ${VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
}
spinner.stop("Upgrade complete")
prompts.outro("Done")
return
/*
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
return
}
const release = args.target
? await specific(args.target).catch(() => {})
@@ -188,5 +104,6 @@ export const UpgradeCommand = {
prompts.log.success(`Successfully upgraded to ${target}`)
prompts.outro("Done")
*/
},
}

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { Global } from "."
import { lazy } from "../util/lazy"
import path from "path"
export namespace GlobalConfig {
export const Info = z.object({
autoupdate: z.boolean().optional(),
provider: z.string().optional(),
model: z.string().optional(),
})
export type Info = z.infer<typeof Info>
export const get = lazy(async () => {
const toml = await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then((mod) => mod.default)
.catch(() => ({}))
return Info.parse(toml)
})
}

View File

@@ -16,6 +16,17 @@ import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { GlobalConfig } from "./global/config"
import { Installation } from "./installation"
;(async () => {
if (Installation.VERSION === "dev") return
const config = await GlobalConfig.get()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
await Installation.upgrade(method, latest).catch(() => {})
})()
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")

View File

@@ -0,0 +1,104 @@
import path from "path"
import { $ } from "bun"
import { z } from "zod"
import { NamedError } from "../util/error"
export namespace Installation {
export type Method = Awaited<ReturnType<typeof method>>
export const Info = z
.object({
version: z.string(),
latest: z.string(),
})
.openapi({
ref: "InstallationInfo",
})
export type Info = z.infer<typeof Info>
export async function info() {
return {
version: VERSION,
latest: await latest(),
}
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).text(),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).text(),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).text(),
},
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
if (output.includes("opencode-ai")) {
return check.name
}
}
return "unknown"
}
export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
export async function upgrade(method: Method, target: string) {
const cmd = (() => {
switch (method) {
case "curl":
return $`curl -fsSL https://opencode.ai/install | bash`
case "npm":
return $`npm install -g opencode-ai@${target}`
case "pnpm":
return $`pnpm install -g opencode-ai@${target}`
case "bun":
return $`bun install -g opencode-ai@${target}`
default:
throw new Error(`Unknown method: ${method}`)
}
})()
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name.slice(1))
}
}

View File

@@ -36,6 +36,7 @@ export namespace ModelsDev {
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(Model),
})
.openapi({

View File

@@ -108,6 +108,7 @@ export namespace Provider {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
models: existing?.models ?? {},
@@ -181,22 +182,22 @@ export namespace Provider {
return state().then((state) => state.providers)
}
async function getSDK(providerID: string) {
async function getSDK(provider: ModelsDev.Provider) {
return (async () => {
using _ = log.time("getSDK", {
providerID,
providerID: provider.id,
})
const s = await state()
const existing = s.sdk.get(providerID)
const existing = s.sdk.get(provider.id)
if (existing) return existing
const [pkg, version] = await ModelsDev.pkg(providerID)
const [pkg, version] = await ModelsDev.pkg(provider.npm ?? provider.id)
const mod = await import(await BunProc.install(pkg, version))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[providerID]?.options)
s.sdk.set(providerID, loaded)
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
return loaded as SDK
})().catch((e) => {
throw new InitError({ providerID: providerID }, { cause: e })
throw new InitError({ providerID: provider.id }, { cause: e })
})
}
@@ -214,8 +215,7 @@ export namespace Provider {
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID })
const sdk = await getSDK(providerID)
const sdk = await getSDK(provider.info)
try {
const language =

View File

@@ -15,6 +15,7 @@ import { NamedError } from "../util/error"
import { Fzf } from "../external/fzf"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
const ERRORS = {
400: {
@@ -466,6 +467,25 @@ export namespace Server {
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

@@ -412,9 +412,7 @@ export namespace Session {
)
const result = streamText({
onStepFinish: async (step) => {
log.info("step finish", {
finishReason: step.finishReason,
})
log.info("step finish", { finishReason: step.finishReason })
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
@@ -430,6 +428,9 @@ export namespace Session {
text = undefined
},
async onFinish(input) {
log.info("message finish", {
reason: input.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost

View File

@@ -8,7 +8,7 @@ require (
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6

View File

@@ -34,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=

View File

@@ -170,16 +170,17 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
go func() {
// TODO: Handle no provider or model setup, yet
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: 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))
}
}()
@@ -187,6 +188,21 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
return tea.Batch(cmds...)
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: 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)
if err != nil {

View File

@@ -59,6 +59,27 @@ func NewCommandRegistry() Registry {
key.WithKeys("f5", "super+t"),
),
},
"share": {
Name: "share",
Description: "create shareable link",
KeyBinding: key.NewBinding(
key.WithKeys("f6"),
),
},
"init": {
Name: "init",
Description: "create or update AGENTS.md",
KeyBinding: key.NewBinding(
key.WithKeys("f7"),
),
},
// "compact": {
// Name: "compact",
// Description: "compact the session",
// KeyBinding: key.NewBinding(
// key.WithKeys("f8"),
// ),
// },
"quit": {
Name: "quit",
Description: "quit",
@@ -68,4 +89,3 @@ func NewCommandRegistry() Registry {
},
}
}

View File

@@ -2,10 +2,14 @@ package completions
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/theme"
)
type CommandCompletionProvider struct {
@@ -27,15 +31,36 @@ func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
t := theme.CurrentTheme()
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Name + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := "/" + cmd.Name
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Name) > space {
space = lipgloss.Width(cmd.Name)
}
}
space += 2
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands {
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
}))
space := space - lipgloss.Width(cmd.Name)
items = append(items, getCommandCompletionItem(cmd, space))
}
return items, nil
}
@@ -45,11 +70,9 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands {
space := space - lipgloss.Width(cmd.Name)
commandNames = append(commandNames, cmd.Name)
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
})
commandMap[cmd.Name] = getCommandCompletionItem(cmd, space)
}
// Find fuzzy matches
@@ -68,4 +91,3 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
return items, nil
}

View File

@@ -24,6 +24,10 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
})
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,

View File

@@ -101,6 +101,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
return m, m.spinner.Tick
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
// Execute the command directly
@@ -421,12 +423,8 @@ func createTextArea(existing *textarea.Model) textarea.Model {
return ta
}
func (m *editorComponent) GetValue() string {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) layout.ModelWithView {
s := spinner.New(
func createSpinner() spinner.Model {
return spinner.New(
spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle(
styles.
@@ -434,6 +432,14 @@ func NewEditorComponent(app *app.App) layout.ModelWithView {
Background(theme.CurrentTheme().Background()).
Width(3)),
)
}
func (m *editorComponent) GetValue() string {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) layout.ModelWithView {
s := createSpinner()
ta := createTextArea(nil)
return &editorComponent{

View File

@@ -13,7 +13,6 @@ import (
)
type CompletionItem struct {
title string
Title string
Value string
}
@@ -35,8 +34,7 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
if selected {
itemStyle = itemStyle.
Foreground(t.Primary()).
Bold(true)
Foreground(t.Primary())
}
title := itemStyle.Render(
@@ -62,6 +60,7 @@ type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string
}
type CompletionSelectedMsg struct {
@@ -250,6 +249,7 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
}
}
@@ -259,7 +259,7 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
li := list.NewListComponent(
[]CompletionItemI{},
7,
"No matches",
completionProvider.GetEmptyMessage(),
false,
)

View File

@@ -18,6 +18,7 @@ type List[T ListItem] interface {
SetItems(items []T)
GetItems() []T
SetSelectedIndex(idx int)
SetEmptyMessage(msg string)
IsEmpty() bool
}
@@ -100,6 +101,10 @@ func (c *listComponent[T]) GetItems() []T {
return c.items
}
func (c *listComponent[T]) SetEmptyMessage(msg string) {
c.fallbackMsg = msg
}
func (c *listComponent[T]) IsEmpty() bool {
return len(c.items) == 0
}

View File

@@ -145,6 +145,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "theme":
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case "share":
a.app.Client.PostSessionShareWithResponse(context.Background(), client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
})
case "init":
return a, a.app.InitializeProject(context.Background())
// case "compact":
// return a, a.app.CompactSession(context.Background())
case "help":
var helpBindings []key.Binding
for _, cmd := range a.app.Commands {

View File

@@ -1518,7 +1518,7 @@ export default function Share(props: {
desc={desc}
data-size="sm"
text={
command + (result() ? `\n${result}` : "")
command + (result() ? `\n${result()}` : "")
}
/>
</div>