Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986144b377 | ||
|
|
1fdb326aa7 | ||
|
|
463257e7e4 | ||
|
|
0f41e60bd6 | ||
|
|
7df81f7b3e | ||
|
|
dd22cb2bb0 | ||
|
|
248325925f | ||
|
|
ca48a4f0fb | ||
|
|
98ee5a3d87 | ||
|
|
67480e5a1c | ||
|
|
2581a9b54c | ||
|
|
14a293e124 | ||
|
|
780419ecae | ||
|
|
f0962e2d9c | ||
|
|
3a9584a419 | ||
|
|
196f42cbff | ||
|
|
322385f6b1 | ||
|
|
b7446cd7b9 | ||
|
|
f618e569ab | ||
|
|
7b394b91e2 | ||
|
|
6a7983a4ea | ||
|
|
737146fca1 | ||
|
|
688f3fd12f | ||
|
|
145df08444 | ||
|
|
8b400515ea | ||
|
|
289797f56d | ||
|
|
be0811ecc3 | ||
|
|
0676bcd4fd | ||
|
|
d076def561 | ||
|
|
e0807d7317 | ||
|
|
fa2723f2d0 | ||
|
|
87d62514db | ||
|
|
2f8cf9146b | ||
|
|
8e0ec6b037 | ||
|
|
6dc434cb83 | ||
|
|
d972c27f03 | ||
|
|
9e2bb63688 | ||
|
|
49053b66a9 | ||
|
|
47497aef07 | ||
|
|
8455029de1 | ||
|
|
9f07f89384 | ||
|
|
d840d43e8f | ||
|
|
9ead2f3dfb | ||
|
|
f3742ddbb8 | ||
|
|
b61a841aa8 | ||
|
|
ebcf11e574 | ||
|
|
065f0aaddf | ||
|
|
c0773dc7c5 | ||
|
|
1c3c74bd36 | ||
|
|
79bbf90b72 | ||
|
|
226a4a7f36 | ||
|
|
df3b424830 | ||
|
|
3cfd9d80bc | ||
|
|
e0553b8d2c | ||
|
|
391c837b37 | ||
|
|
5773d9d1a3 | ||
|
|
ce611963c3 | ||
|
|
f865cacfb8 | ||
|
|
2ec0611f42 | ||
|
|
334161a30e | ||
|
|
dbb6e55226 | ||
|
|
d0f9260559 | ||
|
|
d2176064e1 | ||
|
|
ed8d277e49 | ||
|
|
59b3268c64 | ||
|
|
d043f67761 | ||
|
|
51bf193889 | ||
|
|
b4c2fcccf5 | ||
|
|
e950ad5306 |
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
32
.github/workflows/stats.yml
vendored
Normal 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
|
||||
17
README.md
@@ -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>
|
||||
|
||||
[](https://opencode.ai)
|
||||
[](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
STATS.md
Normal 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) |
|
||||
2
bun.lock
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.5",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"experimental": {
|
||||
"hook": {
|
||||
"file_edited": {
|
||||
".json": []
|
||||
".json": [
|
||||
{
|
||||
"command": ["bun", "run", "prettier", "$FILE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"session_completed": [
|
||||
{
|
||||
|
||||
16
packages/function/sst-env.d.ts
vendored
@@ -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 {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -202,10 +202,7 @@
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output"
|
||||
],
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"limit": {
|
||||
@@ -218,10 +215,7 @@
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"context",
|
||||
"output"
|
||||
],
|
||||
"required": ["context", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"id": {
|
||||
@@ -240,9 +234,7 @@
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"models"
|
||||
],
|
||||
"required": ["models"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
@@ -272,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
|
||||
},
|
||||
{
|
||||
@@ -291,12 +284,13 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -329,9 +323,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -354,9 +346,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -369,4 +359,4 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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, "-")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
packages/opencode/src/cli/bootstrap.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,118 +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
|
||||
}
|
||||
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (args.session) return Session.get(args.session)
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
return Session.create()
|
||||
})()
|
||||
|
||||
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()
|
||||
if (!session) {
|
||||
UI.error("Session not found")
|
||||
return
|
||||
}
|
||||
|
||||
const { providerID, modelID } = args.model
|
||||
? Provider.parseModel(args.model)
|
||||
: await Provider.defaultModel()
|
||||
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_NORMAL_BOLD + "@ ",
|
||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
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()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
packages/opencode/src/cli/cmd/tui.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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({
|
||||
@@ -196,7 +205,7 @@ export namespace Config {
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
ref: "Config.Info",
|
||||
ref: "Config",
|
||||
})
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
54
packages/opencode/src/config/hooks.ts
Normal 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
packages/opencode/src/file/index.ts
Normal 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]: {
|
||||
133
packages/opencode/src/format/formatter.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { App } from "../app/app"
|
||||
import { BunProc } from "../bun"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return Bun.which("gofmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return Bun.which("mix") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
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() {
|
||||
// this is more complicated because we only want to use prettier if it's
|
||||
// being used with the current project
|
||||
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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return Bun.which("zig") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".cxx",
|
||||
".c++",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".hxx",
|
||||
".h++",
|
||||
".ino",
|
||||
".C",
|
||||
".H",
|
||||
],
|
||||
async enabled() {
|
||||
return Bun.which("clang-format") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return Bun.which("ktlint") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
return Bun.which("ruff") !== null
|
||||
},
|
||||
}
|
||||
@@ -1,143 +1,65 @@
|
||||
import { App } from '../app/app'
|
||||
import { BunProc } from '../bun'
|
||||
import { Config } from '../config/config'
|
||||
import { Log } from '../util/log'
|
||||
import path from 'path'
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: 'format' })
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
const state = App.state('format', async () => {
|
||||
const hooks: Record<string, Hook[]> = {}
|
||||
for (const item of FORMATTERS) {
|
||||
if (await item.enabled()) {
|
||||
for (const ext of item.extensions) {
|
||||
const list = hooks[ext] ?? []
|
||||
list.push({
|
||||
command: item.command,
|
||||
environment: item.environment,
|
||||
})
|
||||
hooks[ext] = list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const [file, items] of Object.entries(
|
||||
cfg.experimental?.hook?.file_edited ?? {},
|
||||
)) {
|
||||
for (const item of items) {
|
||||
const list = hooks[file] ?? []
|
||||
list.push({
|
||||
command: item.command,
|
||||
environment: item.environment,
|
||||
})
|
||||
hooks[file] = list
|
||||
}
|
||||
}
|
||||
const state = App.state("format", () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
|
||||
return {
|
||||
hooks,
|
||||
enabled,
|
||||
}
|
||||
})
|
||||
|
||||
export async function run(file: string) {
|
||||
log.info('formatting', { file })
|
||||
const { hooks } = await state()
|
||||
const ext = path.extname(file)
|
||||
const match = hooks[ext]
|
||||
if (!match) return
|
||||
|
||||
for (const item of match) {
|
||||
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,
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error('failed', {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
const s = state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
interface Hook {
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(Formatter)) {
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!isEnabled(item)) continue
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface Native {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
}
|
||||
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)
|
||||
|
||||
const FORMATTERS: Native[] = [
|
||||
{
|
||||
name: 'prettier',
|
||||
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',
|
||||
'.php',
|
||||
'.rb',
|
||||
'.java',
|
||||
'.go',
|
||||
'.rs',
|
||||
'.swift',
|
||||
'.kt',
|
||||
'.kts',
|
||||
'.sol',
|
||||
'.graphql',
|
||||
'.gql',
|
||||
],
|
||||
command: [BunProc.which(), 'run', 'prettier', '--write', '$FILE'],
|
||||
environment: {
|
||||
BUN_BE_BUN: '1',
|
||||
},
|
||||
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',
|
||||
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,
|
||||
})
|
||||
const exit = await proc.exited
|
||||
return exit === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -185,6 +185,7 @@ export namespace Provider {
|
||||
source,
|
||||
info,
|
||||
options,
|
||||
getModel,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}) {
|
||||
@@ -492,15 +502,6 @@ export namespace Session {
|
||||
}
|
||||
text = undefined
|
||||
},
|
||||
async onFinish(input) {
|
||||
log.info("message finish", {
|
||||
reason: input.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
},
|
||||
onError(err) {
|
||||
log.error("callback error", err)
|
||||
switch (true) {
|
||||
@@ -537,6 +538,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,
|
||||
@@ -670,7 +672,7 @@ export namespace Session {
|
||||
value.usage,
|
||||
value.providerMetadata,
|
||||
)
|
||||
assistant.cost = usage.cost
|
||||
assistant.cost += usage.cost
|
||||
await updateMessage(next)
|
||||
if (value.finishReason === "length")
|
||||
throw new Message.OutputLengthError({})
|
||||
@@ -819,7 +821,7 @@ export namespace Session {
|
||||
async onFinish(input) {
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
assistant.cost += usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
next.metadata!.time.completed = Date.now()
|
||||
await updateMessage(next)
|
||||
@@ -853,16 +855,8 @@ export namespace Session {
|
||||
[Symbol.dispose]() {
|
||||
log.info("unlocking", { sessionID })
|
||||
state().pending.delete(sessionID)
|
||||
Config.get().then((cfg) => {
|
||||
if (cfg.experimental?.hook?.session_completed) {
|
||||
for (const item of cfg.experimental.hook.session_completed) {
|
||||
Bun.spawn({
|
||||
cmd: item.command,
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
})
|
||||
}
|
||||
}
|
||||
Bus.publish(Event.Idle, {
|
||||
sessionID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -879,8 +873,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,
|
||||
},
|
||||
}
|
||||
@@ -952,7 +950,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) {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -5,13 +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 { Format } from "../format"
|
||||
import { File } from "../file"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
|
||||
export const EditTool = Tool.define({
|
||||
id: "edit",
|
||||
@@ -60,7 +61,9 @@ export const EditTool = Tool.define({
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
await Bun.write(filepath, params.newString)
|
||||
await Format.run(filepath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,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(
|
||||
@@ -79,14 +82,17 @@ export const EditTool = Tool.define({
|
||||
params.replaceAll,
|
||||
)
|
||||
await file.write(contentNew)
|
||||
await Format.run(filepath)
|
||||
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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +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 { Format } from "../format"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileTime } from "../file/time"
|
||||
|
||||
export const WriteTool = Tool.define({
|
||||
id: "write",
|
||||
@@ -27,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",
|
||||
@@ -43,8 +44,10 @@ export const WriteTool = Tool.define({
|
||||
})
|
||||
|
||||
await Bun.write(filepath, params.content)
|
||||
await Format.run(filepath)
|
||||
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)
|
||||
|
||||
@@ -8,4 +8,3 @@ export function lazy<T>(fn: () => T) {
|
||||
return value as T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
2
packages/opencode/sst-env.d.ts
vendored
@@ -6,4 +6,4 @@
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
export {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
packages/tui/internal/util/concurrency.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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/`,
|
||||
@@ -82,6 +65,7 @@ export default defineConfig({
|
||||
],
|
||||
components: {
|
||||
Hero: "./src/components/Hero.astro",
|
||||
Head: "./src/components/Head.astro",
|
||||
Header: "./src/components/Header.astro",
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -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: "/" },
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
BIN
packages/web/src/assets/lander/screenshot-splash.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
packages/web/src/assets/lander/screenshot.png
Normal file
|
After Width: | Height: | Size: 540 KiB |
18
packages/web/src/assets/logo-ornate-dark.svg
Normal file
@@ -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 |
18
packages/web/src/assets/logo-ornate-light.svg
Normal file
@@ -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 |
|
Before Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 442 KiB |
@@ -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
packages/web/src/components/Head.astro
Normal 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} />
|
||||
)}
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Input Tokens</span>
|
||||
{data().tokens.input ? (
|
||||
<span>{data().tokens.input}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Output Tokens</span>
|
||||
{data().tokens.output ? (
|
||||
<span>{data().tokens.output}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Reasoning Tokens</span>
|
||||
{data().tokens.reasoning ? (
|
||||
<span>{data().tokens.reasoning}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</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>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Input Tokens</span>
|
||||
{data().tokens.input ? (
|
||||
<span>{data().tokens.input}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Output Tokens</span>
|
||||
{data().tokens.output ? (
|
||||
<span>{data().tokens.output}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Reasoning Tokens</span>
|
||||
{data().tokens.reasoning ? (
|
||||
<span>{data().tokens.reasoning}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,14 +26,16 @@ Add a local MCP servers under `mcp.localmcp`.
|
||||
"localmcp": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "my-mcp-command"],
|
||||
"enabled": true,
|
||||
"environment": {
|
||||
"MY_ENV_VAR": "my_env_var_value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also disable a server by setting `enabled` to `false`. This is useful if you want to temporarily disable a server without removing it from your config.
|
||||
|
||||
### Remote
|
||||
|
||||
Add a remote MCP servers under `mcp.remotemcp`.
|
||||
@@ -44,7 +46,8 @@ Add a remote MCP servers under `mcp.remotemcp`.
|
||||
"mcp": {
|
||||
"remotemcp": {
|
||||
"type": "remote",
|
||||
"url": "https://my-mcp-server.com"
|
||||
"url": "https://my-mcp-server.com",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
packages/web/src/types/lang-map.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||