Compare commits

..
94 Commits
Author SHA1 Message Date
Dax Raad ca48a4f0fb better amazon bedrock caching with anthropic models
publish / publish (push) Has been cancelled
2025-06-29 19:27:07 -04:00
DaxandGitHub 98ee5a3d87 Update STATS.md 2025-06-29 13:04:44 -04:00
GitHub Action 67480e5a1c Update download stats 2025-06-29 2025-06-29 12:23:40 +00:00
GitHub Action 2581a9b54c Update download stats 2025-06-29 2025-06-29 02:00:18 +00:00
Dax Raad 14a293e124 ci: stats 2025-06-28 21:59:14 -04:00
Dax Raad 780419ecae ci: daily stats script 2025-06-28 21:57:46 -04:00
Timo ClasenandGitHub f0962e2d9c Add Option to Disable MCP Servers (#513) 2025-06-28 21:05:31 -04:00
Dax Raad 3a9584a419 fix context display
publish / publish (push) Has been cancelled
2025-06-28 21:01:53 -04:00
adamdottv 196f42cbff fix(tui): share command and error messages
publish / publish (push) Has been cancelled
2025-06-28 17:51:28 -05:00
Dax Raad 322385f6b1 patch for scroll dumping characters into input buffer
publish / publish (push) Has been cancelled
2025-06-28 11:56:47 -04:00
Dax Raad b7446cd7b9 ci: fix
publish / publish (push) Has been cancelled
2025-06-28 09:16:29 -04:00
f618e569ab optimize edit-tool rendering (#463)
publish / publish (push) Has been cancelled
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-06-28 06:01:10 -05:00
Jay V 7b394b91e2 docs: share handle slower code blocks 2025-06-27 20:21:28 -04:00
Jay V 6a7983a4ea docs: adding more share images 2025-06-27 20:03:17 -04:00
Jay V 737146fca1 docs: tweak logo 2025-06-27 19:18:54 -04:00
Jay V 688f3fd12f Merge branch 'jeremyosih-feat/scroll-to-bottom-button' into dev 2025-06-27 19:16:46 -04:00
Jay V 145df08444 docs: share page format 2025-06-27 19:16:33 -04:00
Dax RaadandJay V 8b400515ea smooth out initial onboarding flow 2025-06-27 19:10:42 -04:00
Jay V 289797f56d docs: share cleanup title 2025-06-27 19:10:42 -04:00
adamdottvandJay V be0811ecc3 chore: rework openapi spec and use stainless sdk 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 0676bcd4fd temporary patch for input lag on initial run 2025-06-27 19:10:42 -04:00
Polo123456789andJay V d076def561 feat: Add golang file formatting (#474) 2025-06-27 19:10:42 -04:00
Wendell MisiedjanandJay V e0807d7317 fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 19:10:42 -04:00
Jay V fa2723f2d0 docs: update logo screenshot 2025-06-27 19:10:42 -04:00
Jay V 87d62514db docs: share page write tool bug 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 2f8cf9146b ci: ignore 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 8e0ec6b037 ci: aur 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 6dc434cb83 ignore: cleanup 2025-06-27 19:10:42 -04:00
Dax RaadandJay V d972c27f03 lazy load formatters 2025-06-27 19:10:42 -04:00
Ryan WinchesterandJay V 9e2bb63688 feat: add elixir file formatting (#458) 2025-06-27 19:10:42 -04:00
adamdottvandJay V 49053b66a9 fix(web): remove system prompts from share page 2025-06-27 19:10:42 -04:00
TheGoddessInariandJay V 47497aef07 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 19:10:41 -04:00
adamdottvandJay V 8455029de1 fix(tui): min width on user messages 2025-06-27 19:10:41 -04:00
Dax RaadandJay V 9f07f89384 fix formatting output going into tui 2025-06-27 19:10:41 -04:00
adamdottvandJay V d840d43e8f ignore: more metadata in app info 2025-06-27 19:10:41 -04:00
adamdottvandJay V 9ead2f3dfb fix: don't use prettier for langs it doesn't format 2025-06-27 19:10:41 -04:00
Dax RaadandJay V f3742ddbb8 ignore: run prettier 2025-06-27 19:10:41 -04:00
Dax RaadandJay V b61a841aa8 add auto formatting and experimental hooks feature 2025-06-27 19:10:41 -04:00
Jay V ebcf11e574 docs: lander tweak 2025-06-27 19:10:41 -04:00
Jay V 065f0aaddf docs: tweak lander 2025-06-27 19:10:41 -04:00
Dax Raad c0773dc7c5 smooth out initial onboarding flow 2025-06-27 16:09:59 -04:00
Jay V 1c3c74bd36 docs: share cleanup title 2025-06-27 15:31:21 -04:00
adamdottv 79bbf90b72 chore: rework openapi spec and use stainless sdk 2025-06-27 14:26:25 -05:00
Dax Raad 226a4a7f36 temporary patch for input lag on initial run
publish / publish (push) Has been cancelled
2025-06-27 14:36:03 -04:00
Polo123456789andGitHub df3b424830 feat: Add golang file formatting (#474) 2025-06-27 14:11:09 -04:00
Wendell MisiedjanandGitHub 3cfd9d80bc fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 14:09:35 -04:00
Jay V e0553b8d2c docs: update logo screenshot 2025-06-27 14:04:09 -04:00
Jay V 391c837b37 docs: share page write tool bug 2025-06-27 13:25:15 -04:00
Dax Raad 5773d9d1a3 ci: ignore
publish / publish (push) Has been cancelled
2025-06-27 12:37:57 -04:00
Dax Raad ce611963c3 ci: aur
publish / publish (push) Has been cancelled
2025-06-27 12:29:13 -04:00
Dax Raad f865cacfb8 ignore: cleanup
publish / publish (push) Has been cancelled
2025-06-27 11:35:57 -04:00
Dax Raad 2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan WinchesterandGitHub 334161a30e feat: add elixir file formatting (#458) 2025-06-27 10:15:11 -04:00
adamdottv dbb6e55226 fix(web): remove system prompts from share page 2025-06-27 06:48:44 -05:00
TheGoddessInariandGitHub d0f9260559 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 07:40:22 -04:00
adamdottv d2176064e1 fix(tui): min width on user messages 2025-06-27 06:31:13 -05:00
Dax Raad ed8d277e49 fix formatting output going into tui
publish / publish (push) Has been cancelled
2025-06-27 07:29:41 -04:00
adamdottv 59b3268c64 ignore: more metadata in app info 2025-06-27 06:19:27 -05:00
adamdottv d043f67761 fix: don't use prettier for langs it doesn't format
publish / publish (push) Has been cancelled
2025-06-27 05:47:14 -05:00
Dax Raad 51bf193889 ignore: run prettier
publish / publish (push) Has been cancelled
2025-06-26 22:30:44 -04:00
Dax Raad f8b78f08b4 add auto formatting and experimental hooks feature
publish / publish (push) Has been cancelled
2025-06-26 22:17:08 -04:00
Jay V a4f32d602b docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V dc3dd21cf3 docs: tweak lander 2025-06-26 19:02:44 -04:00
Jeremy OsihandGitHub b4c2fcccf5 Merge branch 'sst:dev' into feat/scroll-to-bottom-button 2025-06-27 00:41:20 +02:00
Jeremy Osihandopencode e950ad5306 feat(web): add scroll to last message button
Add intelligent floating scroll button for long conversations that:
- Only appears when scrolling down (direction-aware)
- Auto-hides after 3 seconds of inactivity
- Stays visible on hover to prevent accidental disappearance
- Uses consistent design patterns with repo styling
- Includes proper accessibility features

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: Jeremy Osih <osih.jeremy@gmail.com>
Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-27 00:38:14 +02:00
Dax Raad 8ca713b737 disable task tool temporarily
publish / publish (push) Has been cancelled
2025-06-26 18:27:49 -04:00
Jay V 5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raadandopencode 4bc651f958 fix: improve JSON formatting and add piped output support for run command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V 3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V 863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottvandJay V 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottvandJay V b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottvandJay V 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottvandJay V 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottvandJay V ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottvandJay V 346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottvandJay V dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V 008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottvandJay V ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottvandJay V b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike WallioandJay V 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani PelliandJay V 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax RaadandJay V fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax RaadandJay V f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax RaadandJay V dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
rekram1-node 9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
104 changed files with 2385 additions and 7094 deletions
+32
View File
@@ -0,0 +1,32 @@
name: stats
on:
schedule:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
stats:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run stats script
run: bun scripts/stats.ts
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git push
+5 -12
View File
@@ -1,9 +1,9 @@
<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">
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
</picture>
</a>
</p>
@@ -14,7 +14,7 @@
<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>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
[![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ---------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
+1 -1
View File
@@ -19,7 +19,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"version": "0.0.5",
"bin": {
"opencode": "./bin/opencode",
},
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}
+8 -8
View File
@@ -6,20 +6,20 @@
import "sst"
declare module "sst" {
export interface Resource {
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
Api: cloudflare.Service
Bucket: cloudflare.R2Bucket
}
}
import "sst"
export {}
export {}
+1 -2
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.
+80 -20
View File
@@ -183,6 +183,9 @@
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
@@ -199,10 +202,7 @@
"type": "number"
}
},
"required": [
"input",
"output"
],
"required": ["input", "output"],
"additionalProperties": false
},
"limit": {
@@ -215,14 +215,15 @@
"type": "number"
}
},
"required": [
"context",
"output"
],
"required": ["context", "output"],
"additionalProperties": false
},
"id": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
@@ -233,9 +234,7 @@
"additionalProperties": {}
}
},
"required": [
"models"
],
"required": ["models"],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
@@ -265,12 +264,13 @@
"type": "string"
},
"description": "Environment variables to set when running the MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"command"
],
"required": ["type", "command"],
"additionalProperties": false
},
{
@@ -284,19 +284,79 @@
"url": {
"type": "string",
"description": "URL of the remote MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"url"
],
"required": ["type", "url"],
"additionalProperties": false
}
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["command"],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["command"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.5", "name": "opencode",
"version": "0.0.5",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
+12 -9
View File
@@ -142,7 +142,7 @@ if (!snapshot) {
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
"pkgname='${pkg}'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@@ -166,14 +166,17 @@ if (!snapshot) {
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
// Homebrew formula
const homebrewFormula = [
+28 -23
View File
@@ -13,6 +13,7 @@ export namespace App {
export const Info = z
.object({
user: z.string(),
hostname: z.string(),
git: z.boolean(),
path: z.object({
config: z.string(),
@@ -26,15 +27,25 @@ export namespace App {
}),
})
.openapi({
ref: "App.Info",
ref: "App",
})
export type Info = z.infer<typeof Info>
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
const ctx = Context.create<{
info: Info
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
const APP_JSON = "app.json"
async function create(input: { cwd: string }) {
export type Input = {
cwd: string
}
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
log.info("creating", {
cwd: input.cwd,
})
@@ -62,8 +73,11 @@ export namespace App {
}
>()
const root = git ?? input.cwd
const info: Info = {
user: os.userInfo().username,
hostname: os.hostname(),
time: {
initialized: state.initialized,
},
@@ -72,16 +86,24 @@ export namespace App {
config: Global.Path.config,
state: Global.Path.state,
data,
root: git ?? input.cwd,
root,
cwd: input.cwd,
},
}
const result = {
const app = {
services,
info,
}
return result
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
return result
})
}
export function state<State>(
@@ -107,22 +129,6 @@ export namespace App {
return ctx.use().info
}
export async function provide<T>(
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
return result
})
}
export async function initialize() {
const { info } = ctx.use()
info.time.initialized = Date.now()
@@ -142,4 +148,3 @@ export namespace App {
.replace(/[^A-Za-z0-9_]/g, "-")
}
}
+5 -6
View File
@@ -3,6 +3,7 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -25,11 +26,9 @@ export namespace BunProc {
BUN_BE_BUN: "1",
},
})
const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
log.info("done", {
code,
stdout,
@@ -65,7 +64,7 @@ export namespace BunProc {
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
+4 -2
View File
@@ -49,7 +49,7 @@ export namespace Bus {
)
}
export function publish<Definition extends EventDefinition>(
export async function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -60,12 +60,14 @@ export namespace Bus {
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
sub(payload)
pending.push(sub(payload))
}
}
return Promise.all(pending)
}
export function subscribe<Definition extends EventDefinition>(
+17
View File
@@ -0,0 +1,17 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { Share } from "../share/share"
export async function bootstrap<T>(
input: App.Input,
cb: (app: App.Info) => Promise<T>,
) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
ConfigHooks.init()
return cb(app)
})
}
+8 -8
View File
@@ -31,7 +31,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
@@ -46,14 +46,14 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
@@ -62,11 +62,11 @@ export const AuthListCommand = cmd({
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variables`)
}
},
+2 -2
View File
@@ -8,7 +8,7 @@ export const ModelsCommand = cmd({
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
@@ -16,4 +16,4 @@ export const ModelsCommand = cmd({
}
})
},
})
})
+94 -96
View File
@@ -1,14 +1,13 @@
import type { Argv } from "yargs"
import { App } from "../../app/app"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { Share } from "../../share/share"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -56,110 +55,109 @@ export const RunCommand = cmd({
},
handler: async (args) => {
const message = args.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
},
async () => {
await Share.init()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
if (args.session) return Session.get(args.session)
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
const isPiped = !process.stdout.isTTY
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
}
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || "Unknown")
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata =
message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || 'Unknown')
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
}
UI.empty()
})
},
})
+4 -7
View File
@@ -7,12 +7,9 @@ export const ScrapCommand = cmd({
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
await App.provide({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
+114
View File
@@ -0,0 +1,114 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(
new URL("../../../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}).exited
if (result !== 0) return
UI.empty()
}
}
},
})
+39 -4
View File
@@ -22,6 +22,7 @@ export namespace Config {
}
}
log.info("loaded", result)
return result
})
@@ -36,20 +37,28 @@ export namespace Config {
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
ref: "McpLocalConfig",
})
export const McpRemote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
ref: "McpRemoteConfig",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
@@ -123,7 +132,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.Keybinds",
ref: "KeybindsConfig",
})
export const Info = z
.object({
@@ -167,10 +176,36 @@ export namespace Config {
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
})
.strict()
.openapi({
ref: "Config.Info",
ref: "Config",
})
export type Info = z.output<typeof Info>
+54
View File
@@ -0,0 +1,54 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
const app = App.info()
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async () => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod"
import { Bus } from "../bus"
export namespace File {
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
}
@@ -1,6 +1,6 @@
import { App } from "../../app/app"
import { App } from "../app/app"
export namespace FileTimes {
export namespace FileTime {
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
+165
View File
@@ -0,0 +1,165 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", () => {
const enabled: Record<string, boolean> = {}
return {
enabled,
}
})
async function isEnabled(item: Definition) {
const s = state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of FORMATTERS) {
if (!item.extensions.includes(ext)) continue
if (!isEnabled(item)) continue
result.push(item)
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
}
})
}
interface Definition {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
const FORMATTERS: Definition[] = [
{
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
async enabled() {
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
{
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
try {
const proc = Bun.spawn({
cmd: ["mix", "--version"],
cwd: App.info().path.cwd,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
{
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
try {
const proc = Bun.spawn({
cmd: ["gofmt", "-h"],
cwd: App.info().path.cwd,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
]
}
+18 -109
View File
@@ -1,28 +1,19 @@
import "zod-openapi/extend"
import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Share } from "./share/share"
import url from "node:url"
import { Global } from "./global"
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { AuthCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
const cancel = new AbortController()
@@ -55,103 +46,7 @@ const cli = yargs(hideBin(process.argv))
})
})
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await App.provide({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
await Share.init()
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = url.fileURLToPath(
new URL("../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
signal: cancel.signal,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})
.command(TuiCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(ScrapCommand)
@@ -172,13 +67,14 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
const data: Record<string, any> = {}
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
@@ -186,6 +82,18 @@ try {
cause: e.cause?.toString(),
})
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
@@ -193,6 +101,7 @@ try {
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
process.exitCode = 1
}
cancel.abort()
+4
View File
@@ -26,6 +26,10 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
continue
}
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") {
const client = await experimental_createMCPClient({
+2 -2
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>
+2 -2
View File
@@ -23,7 +23,6 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -186,6 +185,7 @@ export namespace Provider {
source,
info,
options,
getModel,
}
return
}
@@ -411,7 +411,7 @@ export namespace Provider {
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
// TaskTool,
TodoReadTool,
]
@@ -20,6 +20,19 @@ export namespace ProviderTransform {
}
}
}
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
bedrock: {
cachePoint: { type: "ephemeral" },
},
}
}
}
return msgs
}
}
+197 -234
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
}
+25 -7
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 = {
@@ -78,6 +82,12 @@ export namespace Session {
info: Info,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event(
"session.error",
z.object({
@@ -267,7 +277,7 @@ export namespace Session {
sessionID: string
providerID: string
modelID: string
parts: Message.Part[]
parts: Message.MessagePart[]
system?: string[]
tools?: Tool.Info[]
}) {
@@ -537,6 +547,7 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
providerOptions: model.info.options,
@@ -853,6 +864,9 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
},
}
}
@@ -868,8 +882,12 @@ export namespace Session {
reasoning: 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheReadInputTokens"] ??
0) as number,
},
}
@@ -941,7 +959,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) {
+21 -17
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(),
}),
),
}
}
+4 -9
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
@@ -11,12 +10,6 @@ export namespace Share {
let queue: Promise<void> = Promise.resolve()
const pending = new Map<string, any>()
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
})
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
@@ -52,8 +45,10 @@ export namespace Share {
})
}
export async function init() {
await state()
export function init() {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
}
export const URL =
+13 -4
View File
@@ -5,12 +5,14 @@
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
export const EditTool = Tool.define({
id: "edit",
@@ -59,6 +61,9 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
return
}
@@ -67,7 +72,7 @@ export const EditTool = Tool.define({
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTimes.assert(ctx.sessionID, filepath)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
@@ -77,13 +82,17 @@ export const EditTool = Tool.define({
params.replaceAll,
)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTimes.read(ctx.sessionID, filepath)
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
@@ -473,7 +482,7 @@ export function replace(
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
@@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
absPath = path.resolve(process.cwd(), absPath)
}
await FileTimes.assert(ctx.sessionID, absPath)
await FileTime.assert(ctx.sessionID, absPath)
try {
const stats = await fs.stat(absPath)
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
totalAdditions += additions
totalRemovals += removals
FileTimes.read(ctx.sessionID, absPath)
FileTime.read(ctx.sessionID, absPath)
}
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
+2 -2
View File
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { App } from "../app/app"
@@ -90,7 +90,7 @@ export const ReadTool = Tool.define({
// just warms the lsp client
await LSP.touchFile(filePath, true)
FileTimes.read(ctx.sessionID, filePath)
FileTime.read(ctx.sessionID, filePath)
return {
output,
+8 -3
View File
@@ -1,11 +1,13 @@
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
export const WriteTool = Tool.define({
id: "write",
@@ -26,7 +28,7 @@ export const WriteTool = Tool.define({
const file = Bun.file(filepath)
const exists = await file.exists()
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
if (exists) await FileTime.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "write",
@@ -42,7 +44,10 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
FileTimes.read(ctx.sessionID, filepath)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
-1
View File
@@ -8,4 +8,3 @@ export function lazy<T>(fn: () => T) {
return value as T
}
}
+4 -1
View File
@@ -19,7 +19,10 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+4 -4
View File
@@ -316,13 +316,13 @@ const testCases: TestCase[] = [
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: 'test[123]',
replace: 'test[456]',
find: "test[123]",
replace: "test[456]",
},
{
content: 'const regex = "^start.*end$";',
find: '^start.*end$',
replace: '^begin.*finish$',
find: "^start.*end$",
replace: "^begin.*finish$",
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
+1 -2
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
@@ -23,4 +22,4 @@
- **Client**: Generated OpenAPI client communicates with TypeScript server
- **Components**: Reusable UI components in `internal/components/`
- **Themes**: JSON-based theming system with override hierarchy
- **State**: Centralized app state with message passing
- **State**: Centralized app state with message passing
+10 -10
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)
@@ -49,7 +51,12 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
slog.Debug("TUI launched", "app", appInfo)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
@@ -66,19 +73,12 @@ func main() {
program := tea.NewProgram(
tui.NewModel(app_),
// tea.WithColorProfile(colorprofile.ANSI),
tea.WithAltScreen(),
tea.WithKeyboardEnhancements(),
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)
+7 -2
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
+12
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=
+88 -147
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
}
+4 -4
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
@@ -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) {
@@ -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,23 +224,30 @@ 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
}
if message.Role == client.User {
text = strings.ReplaceAll(text, "<", "\\<")
text = strings.ReplaceAll(text, ">", "\\>")
minWidth := max(markdownWidth, (width-4)/2)
messageStyle := styles.NewStyle().
Width(minWidth).
Background(t.BackgroundPanel()).
Foreground(t.Text())
if textWidth < minWidth {
messageStyle = messageStyle.AlignHorizontal(lipgloss.Right)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
}
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()),
@@ -249,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 ""
}
@@ -287,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()
@@ -309,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
@@ -332,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))
@@ -361,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(
@@ -399,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()))
}
}
@@ -409,9 +426,9 @@ func renderToolInvocation(
title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok {
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()))
}
}
@@ -420,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))
@@ -443,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":
@@ -463,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(
@@ -487,6 +507,7 @@ func renderToolInvocation(
false,
false,
true,
messageMetadata,
)
steps = append(steps, step)
}
@@ -498,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 := ""
@@ -530,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))
@@ -709,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
}
@@ -753,7 +771,7 @@ func renderDiagnostics(metadata client.MessageMetadata_Tool_AdditionalProperties
continue
}
line := diag.Range.Start.Line + 1 // 1-based
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
}
@@ -9,13 +9,14 @@ 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"
"github.com/sst/opencode/internal/util"
)
type MessagesComponent interface {
@@ -83,7 +84,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()
@@ -121,99 +122,110 @@ func (m *messagesComponent) renderView() {
return
}
measure := util.Measure("messages.renderView")
defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme()
blocks := make([]string, 0)
previousBlockType := none
for _, message := range m.app.Messages {
var content string
var cached bool
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 +238,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 +267,7 @@ func (m *messagesComponent) renderView() {
}
func (m *messagesComponent) header() string {
if m.app.Session.Id == "" {
if m.app.Session.ID == "" {
return ""
}
@@ -264,8 +277,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"))
}
@@ -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)
}
@@ -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
}
@@ -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)
+23 -9
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -19,6 +20,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
stylesi "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// -------------------------------------------------------------------------
@@ -939,11 +941,22 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
wg := &sync.WaitGroup{}
var leftStr, rightStr string
wg.Add(2)
go func() {
defer wg.Done()
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
}()
go func() {
defer wg.Done()
rightStr = renderRightColumn(fileName, p.right, rightWidth)
}()
wg.Wait()
return leftStr + rightStr + "\n"
})
return sb.String()
}
@@ -957,7 +970,8 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
var sb strings.Builder
for _, h := range diffResult.Hunks {
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
sb.WriteString(unifiedDiff)
}
return sb.String(), nil
@@ -973,7 +987,7 @@ func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (str
var sb strings.Builder
// config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
// sb.WriteString(
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
@@ -981,8 +995,8 @@ func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (str
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
return RenderSideBySideHunk(filename, h, opts...)
})
return sb.String(), nil
}
@@ -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,22 +94,24 @@ 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 {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
if message.Metadata.Assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
-8
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 {
@@ -78,4 +78,3 @@
"syntaxPunctuation": "darkFg"
}
}
@@ -110,4 +110,3 @@
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
}
}
@@ -225,4 +225,4 @@
"light": "#193549"
}
}
}
}
@@ -216,4 +216,4 @@
"light": "#282a36"
}
}
}
}
@@ -239,4 +239,3 @@
}
}
}
@@ -230,4 +230,4 @@
"light": "lightFg"
}
}
}
}
@@ -232,4 +232,4 @@
"light": "lightFg"
}
}
}
}
@@ -218,4 +218,4 @@
"light": "#272822"
}
}
}
}
@@ -243,4 +243,3 @@
}
}
}
@@ -219,4 +219,4 @@
"light": "#292d3e"
}
}
}
}
@@ -231,4 +231,4 @@
"light": "dawnSubtle"
}
}
}
}
@@ -220,4 +220,4 @@
"light": "base00"
}
}
}
}
@@ -223,4 +223,4 @@
"light": "#262335"
}
}
}
}
@@ -241,4 +241,3 @@
}
}
}
@@ -220,4 +220,4 @@
"light": "#3f3f3f"
}
}
}
}
+61 -42
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
@@ -56,6 +56,7 @@ type appModel struct {
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
lastScroll time.Time
}
func (a appModel) Init() tea.Cmd {
@@ -74,19 +75,39 @@ 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}
})
return tea.Batch(cmds...)
}
var BUGGED_SCROLL_KEYS = map[string]bool{
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true,
"M": true,
"m": true,
"[": true,
";": true,
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
keyString := msg.String()
if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] {
return a, nil
}
// 1. Handle active modal
if a.modal != nil {
@@ -222,6 +243,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
a.lastScroll = time.Now()
if a.modal != nil {
return a, nil
}
@@ -267,31 +289,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 +326,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 +338,15 @@ 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.ProviderAuthError:
slog.Error("Failed to authenticate with provider", "error", err.Data.Message)
return a, toast.NewErrorToast("Provider error: " + err.Data.Message)
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 +362,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 +372,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 +525,35 @@ 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,
},
)
response, 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!"))
}
shareUrl := response.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 +661,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
}
+50
View File
@@ -0,0 +1,50 @@
package util
import (
"strings"
"sync"
)
// MapReducePar performs a parallel map-reduce operation on a slice of items.
// It applies a function to each item in the slice concurrently,
// and combines the results serially using a reducer returned from
// each one of the functions, allowing the use of closures.
func MapReducePar[a, b any](items []a, init b, fn func(a) func(b) b) b {
itemCount := len(items)
locks := make([]*sync.Mutex, itemCount)
mapped := make([]func(b) b, itemCount)
for i, value := range items {
lock := &sync.Mutex{}
lock.Lock()
locks[i] = lock
go func() {
defer lock.Unlock()
mapped[i] = fn(value)
}()
}
result := init
for i := range itemCount {
locks[i].Lock()
defer locks[i].Unlock()
f := mapped[i]
if f != nil {
result = f(result)
}
}
return result
}
// WriteStringsPar allows to iterate over a list and compute strings in parallel,
// yet write them in order.
func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
MapReducePar(items, sb, func(item a) func(*strings.Builder) *strings.Builder {
str := fn(item)
return func(sbdr *strings.Builder) *strings.Builder {
sbdr.WriteString(str)
return sbdr
}
})
}
+10
View File
@@ -1,8 +1,10 @@
package util
import (
"log/slog"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
)
@@ -35,3 +37,11 @@ func IsWsl() bool {
return false
}
func Measure(tag string) func(...any) {
startTime := time.Now()
return func(tags ...any) {
args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...)
slog.Info(tag, args...)
}
}
-4
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
+6 -7
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
+4 -19
View File
@@ -8,12 +8,11 @@ import config from "./config.mjs"
import { rehypeHeadingIds } from "@astrojs/markdown-remark"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
const url = "https://opencode.ai"
const github = "https://github.com/sst/opencode"
// https://astro.build/config
export default defineConfig({
site: url,
site: config.url,
output: "server",
adapter: cloudflare({
imageService: "passthrough",
@@ -32,9 +31,7 @@ export default defineConfig({
starlight({
title: "opencode",
expressiveCode: { themes: ["github-light", "github-dark"] },
social: [
{ icon: "github", label: "GitHub", href: config.github },
],
social: [{ icon: "github", label: "GitHub", href: config.github }],
head: [
{
tag: "link",
@@ -43,20 +40,6 @@ export default defineConfig({
href: "/favicon.svg",
},
},
{
tag: "meta",
attrs: {
property: "og:image",
content: `${url}/social-share.png`,
},
},
{
tag: "meta",
attrs: {
property: "twitter:image",
content: `${url}/social-share.png`,
},
},
],
editLink: {
baseUrl: `${github}/edit/master/www/`,
@@ -73,6 +56,7 @@ export default defineConfig({
sidebar: [
"docs",
"docs/cli",
"docs/rules",
"docs/config",
"docs/models",
"docs/themes",
@@ -81,6 +65,7 @@ export default defineConfig({
],
components: {
Hero: "./src/components/Hero.astro",
Head: "./src/components/Head.astro",
Header: "./src/components/Header.astro",
},
plugins: [
+2
View File
@@ -1,4 +1,6 @@
export default {
url: "https://opencode.ai",
socialCard: "https://social-cards.sst.dev",
github: "https://github.com/sst/opencode",
headerLinks: [
{ name: "Home", url: "/" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

@@ -0,0 +1,18 @@
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,18 @@
<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

+20 -11
View File
@@ -18,16 +18,19 @@ function CodeBlock(props: CodeBlockProps) {
const [local, rest] = splitProps(props, ["code", "lang", "onRendered"])
let containerRef!: HTMLDivElement
const [html] = createResource(() => [local.code, local.lang], async ([code, lang]) => {
return (await codeToHtml(code || "", {
lang: lang || "text",
themes: {
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})) as string
})
const [html] = createResource(
() => [local.code, local.lang],
async ([code, lang]) => {
return (await codeToHtml(code || "", {
lang: lang || "text",
themes: {
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})) as string
},
)
onCleanup(() => {
if (containerRef) containerRef.innerHTML = ""
@@ -41,7 +44,13 @@ function CodeBlock(props: CodeBlockProps) {
}
})
return <div ref={containerRef} class={styles.codeblock} {...rest}></div>
return (
<>
{html() ? (
<div ref={containerRef} class={styles.codeblock} {...rest}></div>
) : null}
</>
)
}
export default CodeBlock
+38
View File
@@ -0,0 +1,38 @@
---
import { Base64 } from "js-base64";
import type { Props } from '@astrojs/starlight/props'
import Default from '@astrojs/starlight/components/Head.astro'
import config from '../../config.mjs'
const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, "");
const {
entry: {
data: { title },
},
} = Astro.locals.starlightRoute;
const isDocs = slug.startsWith("docs")
let encodedTitle = '';
let ogImage = `${config.url}/social-share.png`;
if (isDocs) {
// Truncate to fit S3's max key size
encodedTitle = encodeURIComponent(
Base64.encode(
// Convert to ASCII
encodeURIComponent(
// Truncate to fit S3's max key size
title.substring(0, 700)
)
)
);
ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png`;
}
---
<Default {...Astro.props}><slot /></Default>
{ (isDocs || !slug.startsWith("s")) && (
<meta property="og:image" content={ogImage} />
<meta property="twitter:image" content={ogImage} />
)}
+48 -22
View File
@@ -5,7 +5,7 @@ import type { Props } from '@astrojs/starlight/props';
import CopyIcon from "../assets/lander/copy.svg";
import CheckIcon from "../assets/lander/check.svg";
import Screenshot from "../assets/themes/tokyonight.png";
import Screenshot from "../assets/lander/screenshot-splash.png";
const { data } = Astro.locals.starlightRoute.entry;
const { title = data.title, tagline, image, actions = [] } = data.hero || {};
@@ -19,8 +19,10 @@ const imageAttrs = {
const github = config.social.filter(s => s.icon === 'github')[0];
const command = "npm i -g";
const pkg = "opencode-ai";
const command = "curl -fsSL"
const protocol = "https://"
const url = "opencode.ai/install"
const bash = "| bash"
let darkImage: ImageMetadata | undefined;
let lightImage: ImageMetadata | undefined;
@@ -51,11 +53,13 @@ if (image) {
<section class="cta">
<div class="col1">
<a href="/docs">View the docs</a>
<a href="/docs">Docs</a>
</div>
<div class="col2">
<button class="command" data-command={`${command} ${pkg}`}>
<code>{command}&nbsp;<span class="highlight">{pkg}</span></code>
<button class="command" data-command={`${command} ${protocol}${url} ${bash}`}>
<code>
<span>{command}&nbsp;</span><span class="protocol">{protocol}</span><span class="highlight">{url}</span>&nbsp;{bash}
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
@@ -63,7 +67,7 @@ if (image) {
</button>
</div>
<div class="col3">
<a href={github.href}>Star on GitHub</a>
<a href={github.href}>GitHub</a>
</div>
</section>
@@ -133,39 +137,43 @@ section.top {
section.cta {
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: flex-start;
align-items: stretch;
border-top: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
@media (max-width: 50rem) {
flex-direction: column;
& > div.col1 { order: 1; }
& > div.col3 { order: 2; }
& > div.col2 { order: 3; }
}
& > div {
flex: 1;
line-height: 1.4;
padding: calc(var(--padding) / 2) 0.5rem;
@media (max-width: 40rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
}
padding: calc(var(--padding) / 2) 1rem;
a {
font-size: 1rem;
}
}
& > div.col2 {
@media (max-width: 50rem) {
flex: 0 0 auto;
}
}
& > div:not(.col2) {
& > div.col1, & > div.col3 {
flex: 1 1 auto;
text-align: center;
text-transform: uppercase;
@media (max-width: 50rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
}
}
& > div.col2 {
flex: 0 0 auto;
}
& > div + div {
border-left: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
@media (max-width: 50rem) {
border-left: none;
border-top: 2px solid var(--sl-color-border);
}
@@ -183,6 +191,21 @@ section.cta {
code {
color: var(--sl-color-text-secondary);
font-size: 1.125rem;
@media (max-width: 24rem) {
font-size: 0.875rem;
}
@media (max-width: 30rem) {
span.protocol {
display: none;
}
}
@media (max-width: 43rem) {
text-align: center;
span:first-child {
display: block;
}
}
}
code .highlight {
color: var(--sl-color-text);
@@ -192,6 +215,9 @@ section.cta {
.copy {
line-height: 1;
padding: 0;
@media (max-width: 43rem) {
display: none;
}
}
.copy svg {
width: 1rem;
+156 -144
View File
@@ -23,7 +23,6 @@ import {
} from "./icons/custom"
import {
IconFolder,
IconCpuChip,
IconHashtag,
IconSparkles,
IconGlobeAlt,
@@ -40,6 +39,7 @@ import {
IconMagnifyingGlass,
IconWrenchScrewdriver,
IconDocumentMagnifyingGlass,
IconArrowDown,
} from "./icons"
import DiffView from "./DiffView"
import CodeBlock from "./CodeBlock"
@@ -85,7 +85,7 @@ function scrollToAnchor(id: string) {
el.scrollIntoView({ behavior: "smooth" })
}
function stripWorkingDirectory(filePath: string, workingDir?: string) {
function stripWorkingDirectory(filePath?: string, workingDir?: string) {
if (filePath === undefined || workingDir === undefined) return filePath
const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/"
@@ -102,10 +102,7 @@ function stripWorkingDirectory(filePath: string, workingDir?: string) {
}
function getShikiLang(filename: string) {
const ext = filename
.split('.')
.pop()
?.toLowerCase() ?? ''
const ext = filename.split(".").pop()?.toLowerCase() ?? ""
// map.languages(ext) returns an array of matching Linguist language names (e.g. ['TypeScript'])
const langs = map.languages(ext)
@@ -113,12 +110,10 @@ function getShikiLang(filename: string) {
// Overrride any specific language mappings
const overrides: Record<string, string> = {
"conf": "shellscript"
conf: "shellscript",
}
return type
? overrides[type] ?? type
: 'plaintext'
return type ? (overrides[type] ?? type) : "plaintext"
}
function formatDuration(ms: number): string {
@@ -600,12 +595,17 @@ export default function Share(props: {
info: Session.Info
messages: Record<string, Message.Info>
}) {
let lastScrollY = 0
let hasScrolled = false
let scrollTimeout: number | undefined
const id = props.id
const params = new URLSearchParams(window.location.search)
const debug = params.get("debug") === "true"
const [showScrollButton, setShowScrollButton] = createSignal(false)
const [isButtonHovered, setIsButtonHovered] = createSignal(false)
const anchorId = createMemo<string | null>(() => {
const raw = window.location.hash.slice(1)
const [id] = raw.split("-")
@@ -721,6 +721,54 @@ export default function Share(props: {
})
})
function checkScrollNeed() {
const currentScrollY = window.scrollY
const isScrollingDown = currentScrollY > lastScrollY
const scrolled = currentScrollY > 200 // Show after scrolling 200px
const isNearBottom = window.innerHeight + currentScrollY >= document.body.scrollHeight - 100
// Only show when scrolling down, scrolled enough, and not near bottom
const shouldShow = isScrollingDown && scrolled && !isNearBottom
// Update last scroll position
lastScrollY = currentScrollY
if (shouldShow) {
setShowScrollButton(true)
// Clear existing timeout
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// Hide button after 3 seconds of no scrolling (unless hovered)
scrollTimeout = window.setTimeout(() => {
if (!isButtonHovered()) {
setShowScrollButton(false)
}
}, 3000)
} else if (!isButtonHovered()) {
// Only hide if not hovered (to prevent disappearing while user is about to click)
setShowScrollButton(false)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
}
}
onMount(() => {
lastScrollY = window.scrollY // Initialize scroll position
checkScrollNeed()
window.addEventListener("scroll", checkScrollNeed)
window.addEventListener("resize", checkScrollNeed)
})
onCleanup(() => {
window.removeEventListener("scroll", checkScrollNeed)
window.removeEventListener("resize", checkScrollNeed)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
})
const data = createMemo(() => {
const result = {
rootDir: undefined as string | undefined,
@@ -787,59 +835,15 @@ export default function Share(props: {
<h1>{store.info?.title}</h1>
</div>
<div data-section="row">
<ul data-section="stats">
<li>
<span data-element-label>Cost</span>
{data().cost !== undefined ? (
<span>${data().cost.toFixed(2)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Input Tokens</span>
{data().tokens.input ? (
<span>{data().tokens.input}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Output Tokens</span>
{data().tokens.output ? (
<span>{data().tokens.output}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Reasoning Tokens</span>
{data().tokens.reasoning ? (
<span>{data().tokens.reasoning}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
</ul>
<Show when={data().rootDir}>
<ul data-section="stats" data-section-root>
<li title="Project root">
<div data-stat-icon>
<IconFolder width={16} height={16} />
</div>
<span>{data().rootDir}</span>
</li>
<li title="opencode version">
<div data-stat-icon title="opencode">
<IconOpencode width={16} height={16} />
</div>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
</li>
</ul>
</Show>
<ul data-section="stats" data-section-models>
<li title="opencode version">
<div data-stat-icon title="opencode">
<IconOpencode width={16} height={16} />
</div>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
</li>
{Object.values(data().models).length > 0 ? (
<For each={Object.values(data().models)}>
{([provider, model]) => (
@@ -875,6 +879,7 @@ export default function Share(props: {
</span>
)}
</div>
</div>
</div>
@@ -1008,13 +1013,6 @@ export default function Share(props: {
}
>
{(assistant) => {
const system = createMemo(() => {
const prompts = assistant().system || []
return prompts.filter(
(p: string) =>
!p.startsWith("You are Claude"),
)
})
return (
<div
id={anchor()}
@@ -1040,67 +1038,13 @@ export default function Share(props: {
<span data-part-model>
{assistant().modelID}
</span>
<Show when={system().length > 0}>
<div data-part-tool-result>
<ResultsButton
showCopy="Show system prompt"
hideCopy="Hide system prompt"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={system().join("\n\n").trim()}
/>
</Show>
</div>
</Show>
</div>
</div>
</div>
)
}}
</Match>
{/* System text */}
<Match
when={
msg.role === "system" &&
part.type === "text" &&
part
}
>
{(part) => (
<div
id={anchor()}
data-section="part"
data-part-type="system-text"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCpuChip width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>System</span>
</div>
<TextPart
data-size="sm"
text={part().text}
data-color="dimmed"
/>
</div>
</div>
</div>
)}
</Match>
{/* Grep tool */}
<Match
when={
@@ -1293,12 +1237,12 @@ export default function Share(props: {
>
{(_part) => {
const path = createMemo(() =>
toolData()?.args.path !== data().rootDir
toolData()?.args?.path !== data().rootDir
? stripWorkingDirectory(
toolData()?.args.path,
toolData()?.args?.path,
data().rootDir,
)
: toolData()?.args.path,
: toolData()?.args?.path,
)
return (
@@ -1320,7 +1264,9 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>LS</span>
<b>{path()}</b>
<b title={toolData()?.args?.path}>
{path()}
</b>
</div>
<Switch>
<Match when={toolData()?.result}>
@@ -1363,7 +1309,7 @@ export default function Share(props: {
{(_part) => {
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
toolData()?.args?.filePath,
data().rootDir,
),
)
@@ -1386,7 +1332,9 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Read</span>
<b>{filePath()}</b>
<b title={toolData()?.args?.filePath}>
{filePath()}
</b>
</div>
<Switch>
<Match when={hasError()}>
@@ -1458,7 +1406,7 @@ export default function Share(props: {
{(_part) => {
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
toolData()?.args?.filePath,
data().rootDir,
),
)
@@ -1487,7 +1435,9 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Write</span>
<b>{filePath()}</b>
<b title={toolData()?.args?.filePath}>
{filePath()}
</b>
</div>
<Show when={diagnostics().length > 0}>
<ErrorPart>{diagnostics()}</ErrorPart>
@@ -1497,7 +1447,7 @@ export default function Share(props: {
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result,
toolData()?.result
)}
</ErrorPart>
</div>
@@ -1516,7 +1466,7 @@ export default function Share(props: {
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
code={args.content}
code={toolData()?.args?.content}
/>
</div>
</Show>
@@ -1574,7 +1524,9 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Edit</span>
<b>{filePath()}</b>
<b title={toolData()?.args?.filePath}>
{filePath()}
</b>
</div>
<Switch>
<Match when={hasError()}>
@@ -1658,8 +1610,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"todowrite" &&
part.toolInvocation.toolName === "todowrite" &&
part
}
>
@@ -1724,8 +1675,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"webfetch" &&
part.toolInvocation.toolName === "webfetch" &&
part
}
>
@@ -1899,9 +1849,7 @@ export default function Share(props: {
>
<IconSparkles width={18} height={18} />
</Match>
<Match when={msg.role === "system"}>
<IconCpuChip width={18} height={18} />
</Match>
<Match when={msg.role === "user"}>
<IconUserCircle width={18} height={18} />
</Match>
@@ -1927,13 +1875,47 @@ export default function Share(props: {
</For>
)}
</For>
<div data-section="part" data-part-type="connection-status">
<div data-section="part" data-part-type="summary">
<div data-section="decoration">
<span data-status={connectionStatus()[0]}></span>
<div></div>
</div>
<div data-section="content">
<span>{getStatusText(connectionStatus())}</span>
<p data-section="copy">{getStatusText(connectionStatus())}</p>
<ul data-section="stats">
<li>
<span data-element-label>Cost</span>
{data().cost !== undefined ? (
<span>${data().cost.toFixed(2)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Input Tokens</span>
{data().tokens.input ? (
<span>{data().tokens.input}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Output Tokens</span>
{data().tokens.output ? (
<span>{data().tokens.output}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Reasoning Tokens</span>
{data().tokens.reasoning ? (
<span>{data().tokens.reasoning}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
</ul>
</div>
</div>
</div>
@@ -1975,6 +1957,36 @@ export default function Share(props: {
</div>
</div>
</Show>
<Show when={showScrollButton()}>
<button
type="button"
class={styles["scroll-button"]}
onClick={() =>
document.body.scrollIntoView({ behavior: "smooth", block: "end" })
}
onMouseEnter={() => {
setIsButtonHovered(true)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
}}
onMouseLeave={() => {
setIsButtonHovered(false)
if (showScrollButton()) {
scrollTimeout = window.setTimeout(() => {
if (!isButtonHovered()) {
setShowScrollButton(false)
}
}, 3000)
}
}}
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<IconArrowDown width={20} height={20} />
</button>
</Show>
</main>
)
}
+57 -5
View File
@@ -89,7 +89,7 @@
padding: 0;
margin: 0;
display: flex;
gap: 0.5rem 1rem;
gap: 0.5rem 0.875rem;
flex-wrap: wrap;
li {
@@ -104,8 +104,7 @@
}
}
[data-section="stats"][data-section-root],
[data-section="stats"][data-section-models] {
[data-section="stats"] {
li {
gap: 0.3125rem;
@@ -375,7 +374,7 @@
}
}
}
[data-part-type="connection-status"] {
[data-part-type="summary"] {
& > [data-section="decoration"] {
span:first-child {
flex: 0 0 auto;
@@ -405,12 +404,37 @@
}
& > [data-section="content"] {
span {
display: flex;
flex-direction: column;
gap: 0.5rem;
p[data-section="copy"] {
display: block;
line-height: 18px;
font-size: 0.875rem;
color: var(--sl-color-text-dimmed);
}
[data-section="stats"] {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
gap: 0.5rem 0.875rem;
flex-wrap: wrap;
li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--sl-color-text-secondary);
span[data-placeholder] {
color: var(--sl-color-text-dimmed);
}
}
}
}
}
}
@@ -760,3 +784,31 @@
}
}
}
.scroll-button {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.25rem;
border: 1px solid var(--sl-color-divider);
background-color: var(--sl-color-bg-surface);
color: var(--sl-color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease, opacity 0.5s ease;
z-index: 100;
appearance: none;
opacity: 1;
&:active {
transform: translateY(1px);
}
svg {
display: block;
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
- Log in with Anthropic to use your Claude Pro or Claude Max account.
- Supports 75+ LLM providers through [Models.dev](https://models.dev), including local models.
![opencode TUI with the opencode theme](../../../assets/themes/opencode.png)
![opencode TUI with the opencode theme](../../../assets/lander/screenshot.png)
---
@@ -0,0 +1,74 @@
---
title: Rules
---
You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project.
---
## Initialize
To create a new `AGENTS.md` file, you can run the `/init` command in opencode.
:::tip
You should commit your project's `AGENTS.md` file to Git.
:::
This will scan your project and all its contents to understand what the project is about and generate an `AGENTS.md` file with it. This helps opencode to navigate the project better.
If you have an existing `AGENTS.md` file, this will try to add to it.
---
## Example
You can also just create this file manually. Here's an example of some things you can put into an `AGENTS.md` file.
```markdown title="AGENTS.md"
# SST v3 Monorepo Project
This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management.
## Project Structure
- `packages/` - Contains all workspace packages (functions, core, web, etc.)
- `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts)
- `sst.config.ts` - Main SST configuration with dynamic imports
## Code Standards
- Use TypeScript with strict mode enabled
- Shared code goes in `packages/core/` with proper exports configuration
- Functions go in `packages/functions/`
- Infrastructure should be split into logical files in `infra/`
## Monorepo Conventions
- Import shared modules using workspace names: `@my-app/core/example`
```
We are adding project-specific instructions here and this will be shared across your team.
---
## Types
opencode also supports reading the `AGENTS.md` file from multiple locations. And this serves different purposes.
### Project
The ones we have seen above, where the `AGENTS.md` is placed in the project root, are project-specific rules. These only apply when you are working in this directory or its sub-directories.
### Global
You can also have global rules in a `~/.config/opencode/AGENTS.md` file. This gets applied across all opencode sessions.
Since this isn't committed to Git or shared with your team, we recommend using this to specify any personal rules that the LLM should follow.
---
## Precedence
So when opencode starts, it looks for:
1. **Local files** by traversing up from the current directory
2. **Global file** by checking `~/.config/opencode/AGENTS.md`
If you have both global and project-specific rules, opencode will combine them together.
+83 -57
View File
@@ -2,34 +2,96 @@
title: Themes
---
With opencode you can select from one of several built-in themes, use a theme that adapts to your terminal theme, or define your own custom theme.
By default, opencode uses our own `opencode` theme.
---
## Built-in themes
opencode comes with several built-in themes.
| Name | Description |
| --- | --- |
| `system` | Adapts to your terminal's background color |
| `tokyonight` | Based on the Tokyonight theme |
| `everforest` | Based on the Everforest theme |
| `ayu` | Based on the Ayu dark theme |
| `catppuccin` | Based on the Catppuccin theme |
| `gruvbox` | Based on the Gruvbox theme |
| `kanagawa` | Based on the Kanagawa theme |
| `nord` | Based on the Nord theme |
| `matrix` | Hacker-style green on black theme |
| `one-dark` | Based on the Atom One Dark theme |
And more, we are constantly adding new themes.
---
## System theme
The `system` theme is designed to automatically adapt to your terminal's color scheme. Unlike traditional themes that use fixed colors, the _system_ theme:
- **Generates gray scale**: Creates a custom gray scale based on your terminal's background color, ensuring optimal contrast.
- **Uses ANSI colors**: Leverages standard ANSI colors (0-15) for syntax highlighting and UI elements, which respect your terminal's color palette.
- **Preserves terminal defaults**: Uses `none` for text and background colors to maintain your terminal's native appearance.
The system theme is for users who:
- Want opencode to match their terminal's appearance
- Use custom terminal color schemes
- Prefer a consistent look across all terminal applications
---
## Using a theme
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
```json title="opencode.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight"
}
```
---
## Custom themes
opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
## Theme Loading Hierarchy
### Hierarchy
Themes are loaded from multiple directories in the following order (later directories override earlier ones):
Themes are loaded from multiple directories in the following order where later directories override earlier ones:
1. **Built-in themes** - Embedded in the binary
2. **User config directory** - `~/.config/opencode/themes/*.json` (or `$XDG_CONFIG_HOME/opencode/themes/*.json`)
3. **Project root directory** - `<project-root>/.opencode/themes/*.json`
4. **Current working directory** - `./.opencode/themes/*.json`
1. **Built-in themes** - These are embedded in the binary
2. **User config directory** - Defined in `~/.config/opencode/themes/*.json` or `$XDG_CONFIG_HOME/opencode/themes/*.json`
3. **Project root directory** - Defined in the `<project-root>/.opencode/themes/*.json`
4. **Current working directory** - Defined in `./.opencode/themes/*.json`
If multiple directories contain a theme with the same name, the theme from the directory with higher priority will be used.
## Creating a Custom Theme
### Creating a theme
To create a custom theme, create a JSON file in one of the theme directories:
To create a custom theme, create a JSON file in one of the theme directories.
For user-wide themes:
```bash no-frame
# For user-wide themes
mkdir -p ~/.config/opencode/themes
vim ~/.config/opencode/themes/my-theme.json
```
# For project-specific themes
And for project-specific themes.
```bash no-frame
mkdir -p .opencode/themes
vim .opencode/themes/my-theme.json
```
## Theme JSON Format
### JSON format
Themes use a flexible JSON format with support for:
@@ -37,11 +99,13 @@ Themes use a flexible JSON format with support for:
- **ANSI colors**: `3` (0-255)
- **Color references**: `"primary"` or custom definitions
- **Dark/light variants**: `{"dark": "#000", "light": "#fff"}`
- **No color**: `"none"` - Uses the terminal's default color (transparent)
- **No color**: `"none"` - Uses the terminal's default color or transparent
### Example Theme
### Example
```json no-frame
Here's an example of a custom theme:
```json title="my-theme.json"
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
@@ -267,51 +331,13 @@ Themes use a flexible JSON format with support for:
}
```
### Color Definitions
### Color definitions
The `defs` section (optional) allows you to define reusable colors that can be referenced in the theme.
The `defs` section is optional and it allows you to define reusable colors that can be referenced in the theme.
### Using "none" for Terminal Defaults
### Terminal defaults
The special value `\"none\"` can be used for any color to inherit the terminal's default color. This is particularly useful for creating themes that blend seamlessly with your terminal's color scheme:
- `\"text\": \"none\"` - Uses terminal's default foreground color
- `\"background\": \"none\"` - Uses terminal's default background color
## The System Theme
The `system` theme is designed to automatically adapt to your terminal's color scheme. Unlike traditional themes that use fixed colors, the system theme:
- **Generates gray scale**: Creates a custom gray scale based on your terminal's background color, ensuring optimal contrast
- **Uses ANSI colors**: Leverages standard ANSI colors (0-15) for syntax highlighting and UI elements, which respect your terminal's color palette
- **Preserves terminal defaults**: Uses `none` for text and background colors to maintain your terminal's native appearance
The system theme is ideal for users who:
- Want opencode to match their terminal's appearance
- Use custom terminal color schemes
- Prefer a consistent look across all terminal applications
## Built-in Themes
opencode comes with several built-in themes:
- `system` - Default theme that dynamically adapts to your terminal's background color
- `tokyonight` - Tokyonight theme
- `everforest` - Everforest theme
- `ayu` - Ayu dark theme
- `catppuccin` - Catppuccin theme
- `gruvbox` - Gruvbox theme
- `kanagawa` - Kanagawa theme
- `nord` - Nord theme
- `matrix` - Hacker-style green on black theme
- `one-dark` - Atom One Dark inspired theme
## Using a Theme
To use a theme, set it in your opencode configuration or select it from the theme dialog in the TUI.
```json title="opencode.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight"
}
```
- `"text": "none"` - Uses terminal's default foreground color
- `"background": "none"` - Uses terminal's default background color
+2 -2
View File
@@ -6,7 +6,7 @@ hero:
title: The AI coding agent built for the terminal.
tagline: The AI coding agent built for the terminal.
image:
dark: ../../assets/logo-dark.svg
light: ../../assets/logo-light.svg
dark: ../../assets/logo-ornate-dark.svg
light: ../../assets/logo-ornate-light.svg
alt: opencode logo
---
+2 -1
View File
@@ -2,6 +2,7 @@
import { Base64 } from "js-base64";
import config from "virtual:starlight/user-config";
import config from '../../../config.mjs'
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Share from "../../components/Share.tsx";
@@ -38,7 +39,7 @@ const encodedTitle = encodeURIComponent(
)
);
const ogImage = `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?model=${Array.from(models).join(",")}&version=${version}&id=${id}`;
const ogImage = `${config.socialCard}/opencode-share/${encodedTitle}.png?model=${Array.from(models).join(",")}&version=${version}&id=${id}`;
---
<StarlightPage
+27
View File
@@ -0,0 +1,27 @@
declare module "lang-map" {
/** Returned by calling `map()` */
export interface MapReturn {
/** All extensions keyed by language name */
extensions: Record<string, string[]>;
/** All languages keyed by file-extension */
languages: Record<string, string[]>;
}
/**
* Calling `map()` gives you the raw lookup tables:
*
* ```js
* const { extensions, languages } = map();
* ```
*/
function map(): MapReturn;
/** Static method: get extensions for a given language */
namespace map {
function extensions(language: string): string[];
/** Static method: get languages for a given extension */
function languages(extension: string): string[];
}
export = map;
}

Some files were not shown because too many files have changed in this diff Show More