Compare commits

..

34 Commits

Author SHA1 Message Date
Dax Raad 26bab00dab remove opencode_ prefixes from tool names. unfortunately this will break
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 568c04753e feat(tui): expand input to fit message 2025-06-19 08:45:27 -05:00
Dax Raad 4a06e164d2 ensure session.info is synced when shared 2025-06-19 09:41:11 -04:00
adamdottv c57b52c300 fix: include schema in converted toml config 2025-06-19 06:02:02 -05:00
Guillermo Antony Cava Nuñez 0b8f48f17f Fixes tool tip layering (#199) 2025-06-19 00:23:29 -04:00
Dax Raad 3862184ccb hooks 2025-06-19 00:20:03 -04:00
Frank 8619c50976 Update SST 2025-06-18 23:38:06 -04:00
Josh bb6b56b72a fix: incorrect command on main screen for exiting application (#201) 2025-06-18 23:19:43 -04:00
Dax Raad 1252b65166 stop loading models.dev format from global config 2025-06-18 23:08:51 -04:00
Dax Raad 6840276dad docs: update README 2025-06-18 23:03:54 -04:00
Dax Raad bd8c3cd0f1 BREAKING CONFIG CHANGE
We have changed the config format yet again - but this should be the
final time. You can see the readme for more details but the summary is

- got rid of global providers config
- got rid of global toml
- global config is now in `~/.config/opencode/config.json`
- it will be merged with any project level config
2025-06-18 23:01:19 -04:00
Dax Raad e5e9b3e3c0 rework config 2025-06-18 23:01:19 -04:00
Frank 1e8a681de9 Render version 2025-06-18 22:26:51 -04:00
Jay V a834bedc17 ignore: share copy link 2025-06-18 20:18:10 -04:00
Dax Raad 6a3392385e support global config 2025-06-18 18:56:52 -04:00
Jay V 6a00e063c4 ignore: share logo 2025-06-18 18:33:51 -04:00
Jay V 73a0ce2b7d ignore: share 2025-06-18 18:22:19 -04:00
Jay V 4d1afd01fa ignore: share 2025-06-18 18:21:44 -04:00
Jay V 801d5f47bd ignore: share favicon 2025-06-18 18:10:22 -04:00
Jay V b6caae9708 ignore: share 2025-06-18 18:01:34 -04:00
adamdottv 183ca64ef9 feat(tui): show provider next to model 2025-06-18 16:09:49 -05:00
adamdottv 8c32cfe829 chore: tui style tweaks 2025-06-18 15:59:58 -05:00
Jay V 73dcc88da1 ignore: share 2025-06-18 16:54:33 -04:00
Jay V 14bded65dc ignore: share 2025-06-18 16:54:33 -04:00
adamdottv 87d1d3fb62 fix(tui): file completion quirks 2025-06-18 15:51:26 -05:00
Frank e054454109 Api: only return session messages 2025-06-18 16:20:34 -04:00
Dax Raad a6142cf975 ignore: types 2025-06-18 16:20:03 -04:00
Jay V 69332e5fa3 ignore: share 2025-06-18 16:15:22 -04:00
Jay V 20201ba3c4 ignore: share 2025-06-18 16:15:11 -04:00
Dax Raad 658067186a ignore: share page stuff 2025-06-18 16:13:33 -04:00
adamdottv ac777b77cf fix(tui): modal visuals 2025-06-18 15:12:24 -05:00
Dax Raad 5944ae2023 share types 2025-06-18 15:34:13 -04:00
Jay V 2f10961ba8 ignore: share 2025-06-18 15:32:40 -04:00
adamdottv fae97978a3 chore: cleanup logs 2025-06-18 14:18:46 -05:00
58 changed files with 3694 additions and 868 deletions
+49 -96
View File
@@ -61,103 +61,56 @@ The Models.dev dataset is also used to detect common environment variables like
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below.
### Global Config
### Config
Some basic configuration is available in the global config file.
```toml
# ~/.config/opencode/config
theme = "opencode"
provider = "anthropic"
model = "claude-sonnet-4-20250514"
autoupdate = true
keybinds.leader = "ctrl+x"
keybinds.session_new = "<leader>n"
keybinds.editor_open = "<leader>e"
```
#### Keybinds
You can configure the keybinds in the global config file. (Note: values listed below are the defaults.)
```toml
# ~/.config/opencode/config
[keybinds]
leader = "ctrl+x"
help = "<leader>h"
editor_open = "<leader>e"
session_new = "<leader>n"
session_list = "<leader>l"
session_share = "<leader>s"
session_interrupt = "esc"
session_compact = "<leader>c"
tool_details = "<leader>d"
model_list = "<leader>m"
theme_list = "<leader>t"
project_init = "<leader>i"
input_clear = "ctrl+c"
input_paste = "ctrl+v"
input_submit = "enter"
input_newline = "shift+enter"
history_previous = "up"
history_next = "down"
messages_page_up = "pgup"
messages_page_down = "pgdown"
messages_half_page_up = "ctrl+alt+u"
messages_half_page_down = "ctrl+alt+d"
messages_previous = "ctrl+alt+k"
messages_next = "ctrl+alt+j"
messages_first = "ctrl+g"
messages_last = "ctrl+alt+g"
app_exit = "ctrl+c,<leader>q"
```
#### Models.dev
You can also extend the models.dev database with your own providers by mirroring the structure found [here](https://github.com/sst/models.dev/tree/dev/providers/anthropic)
Start with a `provider.toml` file in `~/.config/opencode/providers`
```toml
# ~/.config/opencode/providers/openrouter/provider.toml
[provider]
name = "OpenRouter"
env = ["OPENROUTER_API_KEY"]
npm = "@openrouter/ai-sdk-provider"
[options]
baseURL = "https://api.openrouter.ai" # optional settings
```
And models in `~/.config/opencode/providers/openrouter/models/[model-id]`
```toml
# ~/.config/opencode/providers/openrouter/models/anthropic/claude-3.5-sonnet.toml
name = "Claude 4 Sonnet"
attachment = true
reasoning = false
temperature = true
[cost]
input = 3.00
output = 15.00
inputCached = 3.75
outputCached = 0.30
[limit]
context = 200_000
output = 50_000
```
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo and is meant to be checked in and shared with your team.
Config is optional and can be placed in the root of your repo or globally in `~/.config/opencode/config.json`. It can be checked in and shared with your team.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-20250514" // format is provider/model
"autoshare": false,
"autoupdate": true,
}
```
#### Keybinds
You can configure custom keybinds, the values listed below are the defaults.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"help": "<leader>h",
"editor_open": "<leader>e",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_share": "<leader>s",
"session_interrupt": "esc",
"session_compact": "<leader>c",
"tool_details": "<leader>d",
"model_list": "<leader>m",
"theme_list": "<leader>t",
"project_init": "<leader>i",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter,ctrl+j",
"history_previous": "up",
"history_next": "down",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+alt+k",
"messages_next": "ctrl+alt+j",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"app_exit": "ctrl+c,<leader>q"
}
}
```
@@ -184,7 +137,7 @@ Project configuration is optional. You can place an `opencode.json` file in the
#### Providers
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider.
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider. Remember most popular providers are preloaded from [models.dev](https://models.dev)
```json title="opencode.json"
{
@@ -230,9 +183,7 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"options": {},
"models": {
"anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet"
@@ -243,6 +194,8 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
}
```
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
#### How is this different than Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
+11 -10
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4",
"sst": "3.17.6",
},
},
"packages/function": {
@@ -79,6 +79,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"opencode": "workspace:*",
"typescript": "catalog:",
},
},
@@ -1424,23 +1425,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.4", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.4", "sst-darwin-x64": "3.17.4", "sst-linux-arm64": "3.17.4", "sst-linux-x64": "3.17.4", "sst-linux-x86": "3.17.4", "sst-win32-arm64": "3.17.4", "sst-win32-x64": "3.17.4", "sst-win32-x86": "3.17.4" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-WpAws1ASJIilKC9/DGBhZ5wk2I4gtlzHXKpuwPC25bHWjqllv1jZiehIYhhN0PpV2pV8xCvqzyN8Gdm3J4EWQg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IJansQWlPdiaQNsJw3FQ+Q/ZXN1hzrq2Q31xG4l2HhA1doj1C3y+6s57vu4cTRDFo2OwBlC4+zlQBJHsOYGhrA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-mHd26/AtaQ79ajqzsutRhgEjkCxX+bXgW4KJIN0AGT3110fo2OL0x2UXmfX+sxSWOFHvJQsjFjFm4CLtQSxyBg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-unaNWOY3oEI/jUUG47/2Gbreaoi/D/rLsTPeKyYEWhWEBWCojns7LfMQs1bgW0qjBGmazB2IJD4NVYhYqYQxqQ=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-x64": ["sst-linux-x64@3.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zoErI6dVoRxWcmoVVrzNJWKEqfUF/MyQInEkGROGY2YsFFzOM5RD5Dsdm9q6oDGwx+NxFAhQWc8/8C+OmoW1nA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x86": ["sst-linux-x86@3.17.4", "", { "os": "linux", "cpu": "none" }, "sha512-7ZHS2rxzxVAxMFW3u5+GMRGGACaBMuLht8JYxqruD8mFVqk9UaPQgrFKIHGKWHLBJLVnF2AdwmlHOcEKP+UJWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-q4cedr6WD3NqeQkDvmAsIgMgPIjziIWy81wA3ZmnY6UT0jFgFus23ppLIi6F4BFJfOygvAP2PeGrRR3o8giclw=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-sSQL041YCusZ8/0ynYGe9DCmPYVZOFsemXKUA9tX4IGSDqXae1FN0Sj7HQ17JyY24UUirY1zR7LFk+7KrP6wiA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.4", "", { "os": "win32", "cpu": "none" }, "sha512-WhjsD2dkA2fbQ03CgwIJb+2p0osll2PTXlr7HC3L+H8wG2DgLFPjoE+6N8n6r2dVMVaDzuNwy/7J8hRB29blaw=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
+1 -2
View File
@@ -1,5 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {},
"provider": {}
"mcp": {}
}
+3 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"typecheck": "bun run --filter='*' typecheck"
"typecheck": "bun run --filter='*' typecheck",
"postinstall": "./scripts/hooks"
},
"workspaces": {
"packages": [
@@ -20,7 +21,7 @@
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4"
"sst": "3.17.6"
},
"repository": {
"type": "git",
+6 -8
View File
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
this.ctx.acceptWebSocket(server)
const data = await this.ctx.storage.list()
for (const [key, content] of data.entries()) {
server.send(JSON.stringify({ key, content }))
}
Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
return new Response(null, {
status: 101,
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
public async getData() {
const data = await this.ctx.storage.list()
const messages = []
for (const [key, content] of data.entries()) {
messages.push({ key, content })
}
return messages
return Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => ({ key, content }))
}
private async getSecret() {
+115 -5
View File
@@ -4,11 +4,123 @@
"$schema": {
"type": "string"
},
"theme": {
"type": "string"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
},
"help": {
"type": "string"
},
"editor_open": {
"type": "string"
},
"session_new": {
"type": "string"
},
"session_list": {
"type": "string"
},
"session_share": {
"type": "string"
},
"session_interrupt": {
"type": "string"
},
"session_compact": {
"type": "string"
},
"tool_details": {
"type": "string"
},
"model_list": {
"type": "string"
},
"theme_list": {
"type": "string"
},
"project_init": {
"type": "string"
},
"input_clear": {
"type": "string"
},
"input_paste": {
"type": "string"
},
"input_submit": {
"type": "string"
},
"input_newline": {
"type": "string"
},
"history_previous": {
"type": "string"
},
"history_next": {
"type": "string"
},
"messages_page_up": {
"type": "string"
},
"messages_page_down": {
"type": "string"
},
"messages_half_page_up": {
"type": "string"
},
"messages_half_page_down": {
"type": "string"
},
"messages_previous": {
"type": "string"
},
"messages_next": {
"type": "string"
},
"messages_first": {
"type": "string"
},
"messages_last": {
"type": "string"
},
"app_exit": {
"type": "string"
}
},
"additionalProperties": false
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
},
"provider": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -50,18 +162,16 @@
"output": {
"type": "number"
},
"inputCached": {
"cache_read": {
"type": "number"
},
"outputCached": {
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output",
"inputCached",
"outputCached"
"output"
],
"additionalProperties": false
},
+1 -4
View File
@@ -9,10 +9,7 @@
"dev": "bun run ./src/index.ts"
},
"exports": {
"./*": [
"./src/*.ts",
"./src/*/index.ts"
]
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "1.0.7",
+11 -11
View File
@@ -7,19 +7,19 @@ import { Share } from "../../share/share"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { GlobalConfig } from "../../global/config"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
}
export const RunCommand = cmd({
@@ -60,7 +60,7 @@ export const RunCommand = cmd({
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
const cfg = await GlobalConfig.get()
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
+109 -12
View File
@@ -1,14 +1,19 @@
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = App.state("config", async (app) => {
let result: Info = {}
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp(
file,
@@ -17,7 +22,10 @@ export namespace Config {
)
if (!resolved) continue
try {
result = await import(resolved).then((mod) => Info.parse(mod.default))
result = mergeDeep(
result,
await import(resolved).then((mod) => Info.parse(mod.default)),
)
log.info("found", { path: resolved })
break
} catch (e) {
@@ -34,23 +42,84 @@ export namespace Config {
return result
})
export const McpLocal = z.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
export const McpLocal = z
.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.openapi({
ref: "Config.McpLocal",
})
export const McpRemote = z.object({
type: z.literal("remote"),
url: z.string(),
})
export const McpRemote = z
.object({
type: z.literal("remote"),
url: z.string(),
})
.openapi({
ref: "Config.McpRemote",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Keybinds = z
.object({
leader: z.string().optional(),
help: z.string().optional(),
editor_open: z.string().optional(),
session_new: z.string().optional(),
session_list: z.string().optional(),
session_share: z.string().optional(),
session_interrupt: z.string().optional(),
session_compact: z.string().optional(),
tool_details: z.string().optional(),
model_list: z.string().optional(),
theme_list: z.string().optional(),
project_init: z.string().optional(),
input_clear: z.string().optional(),
input_paste: z.string().optional(),
input_submit: z.string().optional(),
input_newline: z.string().optional(),
history_previous: z.string().optional(),
history_next: z.string().optional(),
messages_page_up: z.string().optional(),
messages_page_down: z.string().optional(),
messages_half_page_up: z.string().optional(),
messages_half_page_down: z.string().optional(),
messages_previous: z.string().optional(),
messages_next: z.string().optional(),
messages_first: z.string().optional(),
messages_last: z.string().optional(),
app_exit: z.string().optional(),
})
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: Keybinds.optional(),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -61,10 +130,38 @@ export namespace Config {
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
})
.strict()
.openapi({
ref: "Config.Info",
})
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
.json()
.then((mod) => Info.parse(mod))
.catch(() => ({}) as Info)
await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return Info.parse(result)
})
export function get() {
return state()
}
-26
View File
@@ -1,26 +0,0 @@
import { z } from "zod"
import { Global } from "."
import { lazy } from "../util/lazy"
import path from "path"
export namespace GlobalConfig {
export const Info = z.object({
provider: z.string().optional(),
model: z.string().optional(),
autoupdate: z.boolean().optional(),
autoshare: z.boolean().optional(),
disabled_providers: z.array(z.string()).optional(),
})
export type Info = z.infer<typeof Info>
export const get = lazy(async () => {
const toml = await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then((mod) => mod.default)
.catch(() => ({}))
return Info.parse(toml)
})
}
+2 -2
View File
@@ -15,9 +15,9 @@ import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { GlobalConfig } from "./global/config"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
@@ -87,7 +87,7 @@ const cli = yargs(hideBin(process.argv))
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await GlobalConfig.get()
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
+1
View File
@@ -32,6 +32,7 @@ export namespace ModelsDev {
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
+5 -33
View File
@@ -1,5 +1,4 @@
import z from "zod"
import path from "path"
import { App } from "../app/app"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
@@ -24,8 +23,6 @@ import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
import { GlobalConfig } from "../global/config"
import { Global } from "../global"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -103,6 +100,7 @@ export namespace Provider {
if (!provider) {
const info = database[id]
if (!info) return
if (info.api) options["baseURL"] = info.api
providers[id] = {
source,
info,
@@ -115,32 +113,6 @@ export namespace Provider {
}
const configProviders = Object.entries(config.provider ?? {})
for await (const providerPath of new Bun.Glob("*/provider.toml").scan({
cwd: Global.Path.providers,
})) {
const [providerID] = providerPath.split("/")
const toml = await import(
path.join(Global.Path.providers, providerPath),
{
with: {
type: "toml",
},
}
).then((mod) => mod.default)
toml.models = {}
const modelsPath = path.join(Global.Path.providers, providerID, "models")
for await (const modelPath of new Bun.Glob("**/*.toml").scan({
cwd: modelsPath,
})) {
const modelID = modelPath.slice(0, -5)
toml.models[modelID] = await import(path.join(modelsPath, modelPath), {
with: {
type: "toml",
},
})
}
configProviders.unshift([providerID, toml])
}
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
@@ -178,7 +150,7 @@ export namespace Provider {
database[providerID] = parsed
}
const disabled = await GlobalConfig.get().then(
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
// load env
@@ -299,10 +271,10 @@ export namespace Provider {
}
export async function defaultModel() {
const cfg = await GlobalConfig.get()
const cfg = await Config.get()
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || cfg.provider === p.info.id))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -332,7 +304,7 @@ export namespace Provider {
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
+20
View File
@@ -15,6 +15,7 @@ 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 = {
400: {
@@ -140,6 +141,25 @@ export namespace Server {
return c.json(App.info())
},
)
.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",
describeRoute({
+17 -4
View File
@@ -30,8 +30,8 @@ import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { GlobalConfig } from "../global/config"
import { Installation } from "../installation"
import { Config } from "../config/config"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -42,7 +42,6 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
share: z
.object({
secret: z.string(),
url: z.string(),
})
.optional(),
@@ -58,6 +57,12 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
Updated: Bus.event(
"session.updated",
@@ -109,7 +114,7 @@ export namespace Session {
log.info("created", result)
state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result)
const cfg = await GlobalConfig.get()
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
share(result.id).then((share) => {
update(result.id, (draft) => {
@@ -132,13 +137,21 @@ export namespace Session {
return read as Info
}
export async function getShare(id: string) {
return Storage.readJSON<ShareInfo>("session/share/" + id)
}
export async function share(id: string) {
const session = await get(id)
if (session.share) return session.share
const share = await Share.create(id)
await update(id, (draft) => {
draft.share = share
draft.share = {
url: share.url,
}
})
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.id, msg)
}
+5 -4
View File
@@ -19,10 +19,11 @@ export namespace Share {
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [, sessionID] = splits
const session = await Session.get(sessionID)
if (!session.share) return
const { secret } = session.share
const [sub, sessionID] = splits
if (sub === "share") return
const share = await Session.getShare(sessionID).catch(() => {})
if (!share) return
const { secret } = share
pending.set(key, content)
queue = queue
.then(async () => {
+1 -1
View File
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
export const BashTool = Tool.define({
id: "opencode.bash",
id: "bash",
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
+2 -2
View File
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
export const EditTool = Tool.define({
id: "opencode.edit",
id: "edit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
: path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "opencode.edit",
id: "edit",
sessionID: ctx.sessionID,
title: "Edit this file: " + filepath,
metadata: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
export const GlobTool = Tool.define({
id: "opencode.glob",
id: "glob",
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
import DESCRIPTION from "./grep.txt"
export const GrepTool = Tool.define({
id: "opencode.grep",
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
+1 -1
View File
@@ -21,7 +21,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.define({
id: "opencode.list",
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt"
export const LspDiagnosticTool = Tool.define({
id: "opencode.lsp_diagnostics",
id: "lsp_diagnostics",
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt"
export const LspHoverTool = Tool.define({
id: "opencode.lsp_hover",
id: "lsp_hover",
description: DESCRIPTION,
parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "path"
import { App } from "../app/app"
export const MultiEditTool = Tool.define({
id: "opencode.multiedit",
id: "multiedit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
+1 -1
View File
@@ -232,7 +232,7 @@ async function applyCommit(
}
export const PatchTool = Tool.define({
id: "opencode.patch",
id: "patch",
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params, ctx) => {
+1 -1
View File
@@ -12,7 +12,7 @@ const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define({
id: "opencode.read",
id: "read",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({
id: "opencode.task",
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
+2 -2
View File
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
})
export const TodoWriteTool = Tool.define({
id: "opencode.todowrite",
id: "todowrite",
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"),
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
})
export const TodoReadTool = Tool.define({
id: "opencode.todoread",
id: "todoread",
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
+1 -1
View File
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define({
id: "opencode.webfetch",
id: "webfetch",
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
+2 -2
View File
@@ -8,7 +8,7 @@ import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
export const WriteTool = Tool.define({
id: "opencode.write",
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "opencode.write",
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
+34 -16
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"path/filepath"
"sort"
"strings"
"log/slog"
@@ -22,8 +23,9 @@ type App struct {
Info client.AppInfo
Version string
StatePath string
Config *config.Config
Configg *client.ConfigInfo
Client *client.ClientWithResponses
State *config.State
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
@@ -42,6 +44,9 @@ type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggerdMsg struct {
InitialValue string
}
func New(
ctx context.Context,
@@ -51,14 +56,17 @@ func New(
) (*App, error) {
RootPath = appInfo.Path.Root
appConfigPath := filepath.Join(appInfo.Path.Config, "config")
appConfig, err := config.LoadConfig(appConfigPath)
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
if err != nil {
appConfig = config.NewConfig()
return nil, err
}
if len(appConfig.Keybinds) == 0 {
appConfig.Keybinds = make(map[string]string)
appConfig.Keybinds["leader"] = "ctrl+x"
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
@@ -68,20 +76,31 @@ func New(
config.SaveState(appStatePath, appState)
}
mergedConfig := config.MergeState(appState, appConfig)
theme.SetTheme(mergedConfig.Theme)
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
slog.Debug("Loaded config", "config", mergedConfig)
if appState.Theme != "" {
theme.SetTheme(appState.Theme)
}
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: mergedConfig,
Configg: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(mergedConfig),
Commands: commands.LoadFromConfig(configInfo),
}
return app, nil
@@ -127,11 +146,11 @@ func (a *App) InitializeProvider() tea.Cmd {
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
for _, provider := range providers {
if provider.Id == a.Config.Provider {
if provider.Id == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.Id == a.Config.Model {
if model.Id == a.State.Model {
currentModel = &model
}
}
@@ -179,8 +198,7 @@ func (a *App) IsBusy() bool {
}
func (a *App) SaveState() {
state := config.ConfigToState(a.Config)
err := config.SaveState(a.StatePath, state)
err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
+21 -21
View File
@@ -1,11 +1,12 @@
package commands
import (
"encoding/json"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/pkg/client"
)
type ExecuteCommandMsg Command
@@ -106,13 +107,6 @@ func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
return false
}
func (k Command) FromConfig(config *config.Config) Command {
if keybind, ok := config.Keybinds[string(k.Name)]; ok {
k.Keybindings = parseBindings(keybind)
}
return k
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
@@ -129,7 +123,7 @@ func parseBindings(bindings ...string) []Keybinding {
return parsedBindings
}
func LoadFromConfig(config *config.Config) CommandRegistry {
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
@@ -214,18 +208,18 @@ func LoadFromConfig(config *config.Config) CommandRegistry {
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter"),
},
{
Name: HistoryPreviousCommand,
Description: "previous prompt",
Keybindings: parseBindings("up"),
},
{
Name: HistoryNextCommand,
Description: "next prompt",
Keybindings: parseBindings("down"),
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
@@ -274,8 +268,14 @@ func LoadFromConfig(config *config.Config) CommandRegistry {
},
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
registry[command.Name] = command.FromConfig(config)
if keybind, ok := keybinds[string(command.Name)]; ok {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
return registry
}
+4 -1
View File
@@ -20,10 +20,13 @@ func NewCompletionManager(app *app.App) *CompletionManager {
}
}
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
return m.providers["commands"]
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}
+37 -30
View File
@@ -6,12 +6,12 @@ import (
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -23,6 +23,8 @@ type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Content() string
Lines() int
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
@@ -50,22 +52,15 @@ func (m *editorComponent) Init() tea.Cmd {
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// // TODO: ?
// if key.Matches(msg, messageKeys.PageUp) ||
// key.Matches(msg, messageKeys.PageDown) ||
// key.Matches(msg, messageKeys.HalfPageUp) ||
// key.Matches(msg, messageKeys.HalfPageDown) {
// return m, nil
// }
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
@@ -73,14 +68,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(
commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
)
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
m.textarea.SetValue(modifiedValue + " ")
return m, nil
}
}
@@ -94,7 +90,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) View() string {
func (m *editorComponent) Content() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
@@ -115,7 +111,7 @@ func (m *editorComponent) View() string {
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
BorderBackground(t.Background()).
Render(textarea)
@@ -126,7 +122,7 @@ func (m *editorComponent) View() string {
model := ""
if m.app.Model != nil {
model = base(m.app.Model.Name) + muted(" • /model")
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
@@ -139,6 +135,13 @@ func (m *editorComponent) View() string {
return content
}
func (m *editorComponent) View() string {
if m.Lines() > 1 {
return ""
}
return m.Content()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
@@ -146,18 +149,21 @@ func (m *editorComponent) GetSize() (width, height int) {
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height - 4) // account for info underneath
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
// m.textarea.SetHeight(height - 4)
return nil
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
func (m *editorComponent) Value() string {
return strings.TrimSpace(m.textarea.Value())
return m.textarea.Value()
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := m.Value()
m.textarea.Reset()
value := strings.TrimSpace(m.Value())
if value == "" {
return m, nil
}
@@ -167,6 +173,11 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
return m, nil
}
var cmds []tea.Cmd
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
@@ -180,12 +191,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
m.attachments = nil
return m, tea.Batch(
util.CmdHandler(app.SendMsg{
Text: value,
Attachments: attachments,
}),
)
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
@@ -253,7 +253,7 @@ func renderToolInvocation(
showDetails bool,
isLast bool,
) string {
ignoredTools := []string{"opencode_todoread"}
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
return ""
}
@@ -350,7 +350,7 @@ func renderToolInvocation(
title := ""
switch toolCall.ToolName {
case "opencode_read":
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("READ %s %s", toolArgs, elapsed)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
@@ -358,7 +358,7 @@ func renderToolInvocation(
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "opencode_edit":
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s %s", relative(filename), elapsed)
if d, ok := metadata.Get("diff"); ok {
@@ -399,14 +399,14 @@ func renderToolInvocation(
)
}
}
case "opencode_write":
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("WRITE %s %s", relative(filename), elapsed)
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "opencode_bash":
case "bash":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s %s", description, elapsed)
}
@@ -417,7 +417,7 @@ func renderToolInvocation(
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_webfetch":
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("FETCH %s %s", toolArgs, elapsed)
if format, ok := toolArgsMap["format"].(string); ok {
@@ -428,7 +428,7 @@ func renderToolInvocation(
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_todowrite":
case "todowrite":
title = fmt.Sprintf("PLAN %s", elapsed)
if to, ok := metadata.Get("todos"); ok && finished {
@@ -498,11 +498,11 @@ func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
case "list":
return "LIST"
case "opencode_webfetch":
case "webfetch":
return "FETCH"
case "opencode_todowrite":
case "todowrite":
return "PLAN"
default:
normalizedName := name
@@ -559,27 +559,27 @@ func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
case "bash":
return "Building command..."
case "opencode_edit":
case "edit":
return "Preparing edit..."
case "opencode_fetch":
case "webfetch":
return "Writing fetch..."
case "opencode_glob":
case "glob":
return "Finding files..."
case "opencode_grep":
case "grep":
return "Searching content..."
case "opencode_ls":
case "list":
return "Listing directory..."
case "opencode_read":
case "read":
return "Reading file..."
case "opencode_write":
case "write":
return "Preparing write..."
case "opencode_todowrite", "opencode_todoread":
case "todowrite", "todoread":
return "Planning..."
case "opencode_patch":
case "patch":
return "Preparing patch..."
case "opencode_batch":
case "batch":
return "Running batch operations..."
}
return "Working..."
@@ -266,7 +266,7 @@ func (m *messagesComponent) header() string {
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
Render(header)
@@ -322,7 +322,7 @@ func (m *messagesComponent) home() string {
{"/new", "start a new session"},
{"/model", "switch model"},
{"/theme", "switch theme"},
{"/quit", "exit the app"},
{"/exit", "exit the app"},
}
commandLines := []string{}
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -100,10 +101,10 @@ type completionDialogKeyMap struct {
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
key.WithKeys("tab", "enter", "right"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
),
}
@@ -111,42 +112,15 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
slog.Info("CompletionDialog", "key", msg.String(), "focused", true)
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
@@ -162,7 +136,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.query = query
cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query)
slog.Info("CompletionDialog", "query", query, "items", len(items))
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
@@ -192,18 +165,16 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, tea.Batch(cmds...)
} else {
slog.Info("CompletionDialog", "key", msg.String(), "focused", false)
cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
// status.Error(err.Error())
}
return items
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
c.pseudoSearchTextArea.SetValue(msg.String())
// c.pseudoSearchTextArea.SetValue(msg.String())
return c, tea.Batch(cmds...)
}
case tea.WindowSizeMsg:
@@ -238,7 +209,7 @@ func (c *completionDialogComponent) View() string {
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
Width(c.width).
Render(c.list.View())
}
@@ -258,6 +229,34 @@ func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
}
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
@@ -271,7 +270,7 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
// status.Error(err.Error())
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
@@ -83,6 +83,6 @@ type HelpDialog interface {
func NewHelpDialog(commands []commands.Command) HelpDialog {
return &helpDialog{
commands: commands,
modal: modal.New(),
modal: modal.New(modal.WithTitle("Help")),
}
}
@@ -180,6 +180,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)
}
@@ -189,14 +190,6 @@ func (m *modelDialog) View() string {
Background(t.BackgroundElement()).
Foreground(t.Text())
// Capitalize first letter of provider name
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
@@ -217,8 +210,9 @@ func (m *modelDialog) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
baseStyle.
Width(maxDialogWidth).
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
@@ -238,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
}
if m.hScrollPossible {
if m.hScrollOffset > 0 {
indicator = "← " + indicator
}
if m.hScrollOffset < len(m.availableProviders)-1 {
indicator += "→"
}
indicator = "← " + indicator + "→"
}
if indicator == "" {
@@ -313,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
hScrollOffset: 0,
hScrollPossible: len(availableProviders) > 1,
provider: availableProviders[0],
modal: modal.New(),
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
}
}
@@ -19,14 +19,12 @@ type SessionDialog interface {
layout.Modal
}
type sessionItem struct {
session client.SessionInfo
}
type sessionItem client.SessionInfo
func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 2).
Width(width - 4).
Background(t.BackgroundElement())
if selected {
@@ -39,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
Foreground(t.Text())
}
return baseStyle.Padding(0, 1).Render(s.session.Title)
return baseStyle.Padding(0, 1).Render(s.Title)
}
type sessionDialog struct {
@@ -60,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyMsg:
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
selectedSession := item.session
s.selectedSessionID = selectedSession.Id
s.selectedSessionID = item.Id
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
util.CmdHandler(app.SessionSelectedMsg(&item)),
)
}
}
@@ -94,7 +91,10 @@ func NewSessionDialog(app *app.App) SessionDialog {
var sessionItems []sessionItem
for _, sess := range sessions {
sessionItems = append(sessionItems, sessionItem{session: sess})
if sess.ParentID != nil {
continue
}
sessionItems = append(sessionItems, sessionItem(sess))
}
list := list.NewListComponent(
@@ -1,9 +1,10 @@
package list
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
type ListItem interface {
@@ -148,7 +149,7 @@ func (c *listComponent[T]) View() string {
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
return strings.Join(listItems, "\n")
}
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
@@ -1,6 +1,8 @@
package modal
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
return m
}
func (m *Modal) SetTitle(title string) {
m.title = title
}
// Render renders the modal centered on the screen
func (m *Modal) Render(contentView string, background string) string {
t := theme.CurrentTheme()
@@ -95,15 +101,21 @@ func (m *Modal) Render(contentView string, background string) string {
titleStyle := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(innerWidth).
Padding(0, 1)
titleView := titleStyle.Render(m.title)
finalContent = lipgloss.JoinVertical(
lipgloss.Left,
titleView,
contentView,
)
// titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escText := escStyle.Render("esc")
// Calculate position for esc text
titleWidth := lipgloss.Width(m.title)
escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine)
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
} else {
finalContent = contentView
}
@@ -0,0 +1,125 @@
// Package memoization implement a simple memoization cache. It's designed to
// improve performance in textarea.
package textarea
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
@@ -0,0 +1,102 @@
// Package runeutil provides utility functions for tidying up incoming runes
// from Key messages.
package textarea
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
File diff suppressed because it is too large Load Diff
+4 -45
View File
@@ -7,6 +7,7 @@ import (
"os"
"github.com/BurntSushi/toml"
"github.com/sst/opencode/pkg/client"
)
type State struct {
@@ -15,44 +16,15 @@ type State struct {
Model string `toml:"model"`
}
type Config struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Keybinds map[string]string `toml:"keybinds"`
}
func NewState() *State {
return &State{
Theme: "opencode",
}
}
func NewConfig() *Config {
keybinds := make(map[string]string)
keybinds["leader"] = "ctrl+x"
return &Config{
Keybinds: keybinds,
}
}
func ConfigToState(config *Config) *State {
return &State{
Theme: config.Theme,
Provider: config.Provider,
Model: config.Model,
}
}
func MergeState(state *State, config *Config) *Config {
if config.Theme == "" {
config.Theme = state.Theme
}
if config.Provider == "" {
config.Provider = state.Provider
}
if config.Model == "" {
config.Model = state.Model
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
if config.Theme == nil {
config.Theme = &state.Theme
}
return config
}
@@ -79,19 +51,6 @@ func SaveState(filePath string, state *State) error {
return nil
}
// LoadConfig reads a Config struct from the specified TOML file.
// It returns a pointer to the Config struct and an error if any issues occur.
func LoadConfig(filePath string) (*Config, error) {
var config Config
if _, err := toml.DecodeFile(filePath, &config); err != nil {
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr)
}
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
}
return &config, nil
}
// LoadState loads the state from the specified TOML file.
// It returns a pointer to the State struct and an error if any issues occur.
func LoadState(filePath string) (*State, error) {
+66 -18
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -90,10 +91,49 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 3. Handle completions trigger
switch msg.String() {
case "/":
a.showCompletionDialog = true
if !a.showCompletionDialog {
a.showCompletionDialog = true
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
}
if a.showCompletionDialog {
switch msg.String() {
case "tab", "enter", "esc", "ctrl+c":
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
}
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
@@ -106,11 +146,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
// Doesn't forward event if enter key is pressed
// if msg.String() == "enter" {
// return a, tea.Batch(cmds...)
// }
}
// 4. Maximize editor responsiveness for printable characters
@@ -173,6 +208,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.completions.SetProvider(a.completionManager.DefaultProvider())
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info
@@ -204,16 +240,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
var err error
a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
a.app.Messages, err = a.app.ListMessages(context.Background(), msg.Id)
if err != nil {
slog.Error("Failed to list messages", "error", err)
}
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.Config.Provider = msg.Provider.Id
a.app.Config.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.Config.Theme = msg.ThemeName
a.app.State.Theme = msg.ThemeName
a.app.SaveState()
}
@@ -250,14 +290,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a appModel) View() string {
layoutView := a.layout.View()
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
if a.editor.Lines() > 1 {
editorY = editorY - a.editor.Lines() + 1
layoutView = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
layoutView,
)
}
if a.showCompletionDialog {
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
@@ -455,7 +503,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
func NewModel(app *app.App) tea.Model {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.GetProvider("")
initialProvider := completionManager.DefaultProvider()
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
@@ -469,8 +517,8 @@ func NewModel(app *app.App) tea.Model {
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if leader, ok := app.Config.Keybinds["leader"]; ok {
binding := key.NewBinding(key.WithKeys(leader))
if (*app.Configg.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Configg.Keybinds.Leader))
leaderBinding = &binding
}
@@ -490,7 +538,7 @@ func NewModel(app *app.App) tea.Model {
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(6),
layout.FlexChildSizeFixed(5),
),
),
}
+337 -45
View File
@@ -44,6 +44,25 @@
"description": "Get app info"
}
},
"/config_get": {
"post": {
"responses": {
"200": {
"description": "Get config info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Info"
}
}
}
}
},
"operationId": "postConfig_get",
"parameters": [],
"description": "Get config info"
}
},
"/app_initialize": {
"post": {
"responses": {
@@ -518,14 +537,14 @@
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
}
],
"discriminator": {
@@ -536,9 +555,9 @@
"permission.updated": "#/components/schemas/Event.permission.updated",
"message.updated": "#/components/schemas/Event.message.updated",
"message.part.updated": "#/components/schemas/Event.message.part.updated",
"installation.updated": "#/components/schemas/Event.installation.updated",
"session.updated": "#/components/schemas/Event.session.updated",
"session.error": "#/components/schemas/Event.session.error",
"installation.updated": "#/components/schemas/Event.installation.updated"
"session.error": "#/components/schemas/Event.session.error"
}
}
},
@@ -1182,6 +1201,30 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"Event.session.updated": {
"type": "object",
"properties": {
@@ -1220,21 +1263,20 @@
"share": {
"type": "object",
"properties": {
"secret": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"secret",
"url"
]
},
"title": {
"type": "string"
},
"version": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
@@ -1254,6 +1296,7 @@
"required": [
"id",
"title",
"version",
"time"
]
},
@@ -1292,30 +1335,6 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"App.Info": {
"type": "object",
"properties": {
@@ -1368,21 +1387,243 @@
"time"
]
},
"Error": {
"Config.Info": {
"type": "object",
"properties": {
"data": {
"$schema": {
"type": "string"
},
"theme": {
"type": "string"
},
"keybinds": {
"$ref": "#/components/schemas/Config.Keybinds"
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
},
"provider": {
"type": "object",
"additionalProperties": {}
"additionalProperties": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output"
]
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
]
},
"id": {
"type": "string"
}
}
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
]
}
},
"mcp": {
"type": "object",
"additionalProperties": {
"oneOf": [
{
"$ref": "#/components/schemas/Config.McpLocal"
},
{
"$ref": "#/components/schemas/Config.McpRemote"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"local": "#/components/schemas/Config.McpLocal",
"remote": "#/components/schemas/Config.McpRemote"
}
}
}
}
},
"required": [
"data"
]
}
},
"Config.Keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
},
"help": {
"type": "string"
},
"editor_open": {
"type": "string"
},
"session_new": {
"type": "string"
},
"session_list": {
"type": "string"
},
"session_share": {
"type": "string"
},
"session_interrupt": {
"type": "string"
},
"session_compact": {
"type": "string"
},
"tool_details": {
"type": "string"
},
"model_list": {
"type": "string"
},
"theme_list": {
"type": "string"
},
"project_init": {
"type": "string"
},
"input_clear": {
"type": "string"
},
"input_paste": {
"type": "string"
},
"input_submit": {
"type": "string"
},
"input_newline": {
"type": "string"
},
"history_previous": {
"type": "string"
},
"history_next": {
"type": "string"
},
"messages_page_up": {
"type": "string"
},
"messages_page_down": {
"type": "string"
},
"messages_half_page_up": {
"type": "string"
},
"messages_half_page_down": {
"type": "string"
},
"messages_previous": {
"type": "string"
},
"messages_next": {
"type": "string"
},
"messages_first": {
"type": "string"
},
"messages_last": {
"type": "string"
},
"app_exit": {
"type": "string"
}
}
},
"Provider.Info": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -1436,18 +1677,16 @@
"output": {
"type": "number"
},
"inputCached": {
"cache_read": {
"type": "number"
},
"outputCached": {
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output",
"inputCached",
"outputCached"
"output"
]
},
"limit": {
@@ -1479,6 +1718,59 @@
"id"
]
},
"Config.McpLocal": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"type",
"command"
]
},
"Config.McpRemote": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
]
},
"Error": {
"type": "object",
"properties": {
"data": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"data"
]
},
"InstallationInfo": {
"type": "object",
"properties": {
+321 -35
View File
@@ -39,6 +39,100 @@ type AppInfo struct {
User string `json:"user"`
}
// ConfigInfo defines model for Config.Info.
type ConfigInfo struct {
Schema *string `json:"$schema,omitempty"`
// Autoshare Share newly created sessions automatically
Autoshare *bool `json:"autoshare,omitempty"`
// Autoupdate Automatically update to the latest version
Autoupdate *bool `json:"autoupdate,omitempty"`
// DisabledProviders Disable providers that are loaded automatically
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"`
// Model Model to use in the format of provider/model, eg anthropic/claude-2
Model *string `json:"model,omitempty"`
Provider *map[string]struct {
Api *string `json:"api,omitempty"`
Env *[]string `json:"env,omitempty"`
Id *string `json:"id,omitempty"`
Models map[string]struct {
Attachment *bool `json:"attachment,omitempty"`
Cost *struct {
CacheRead *float32 `json:"cache_read,omitempty"`
CacheWrite *float32 `json:"cache_write,omitempty"`
Input float32 `json:"input"`
Output float32 `json:"output"`
} `json:"cost,omitempty"`
Id *string `json:"id,omitempty"`
Limit *struct {
Context float32 `json:"context"`
Output float32 `json:"output"`
} `json:"limit,omitempty"`
Name *string `json:"name,omitempty"`
Reasoning *bool `json:"reasoning,omitempty"`
Temperature *bool `json:"temperature,omitempty"`
} `json:"models"`
Name *string `json:"name,omitempty"`
Npm *string `json:"npm,omitempty"`
Options *map[string]interface{} `json:"options,omitempty"`
} `json:"provider,omitempty"`
Theme *string `json:"theme,omitempty"`
}
// ConfigInfo_Mcp_AdditionalProperties defines model for Config.Info.mcp.AdditionalProperties.
type ConfigInfo_Mcp_AdditionalProperties struct {
union json.RawMessage
}
// ConfigKeybinds defines model for Config.Keybinds.
type ConfigKeybinds struct {
AppExit *string `json:"app_exit,omitempty"`
EditorOpen *string `json:"editor_open,omitempty"`
Help *string `json:"help,omitempty"`
HistoryNext *string `json:"history_next,omitempty"`
HistoryPrevious *string `json:"history_previous,omitempty"`
InputClear *string `json:"input_clear,omitempty"`
InputNewline *string `json:"input_newline,omitempty"`
InputPaste *string `json:"input_paste,omitempty"`
InputSubmit *string `json:"input_submit,omitempty"`
Leader *string `json:"leader,omitempty"`
MessagesFirst *string `json:"messages_first,omitempty"`
MessagesHalfPageDown *string `json:"messages_half_page_down,omitempty"`
MessagesHalfPageUp *string `json:"messages_half_page_up,omitempty"`
MessagesLast *string `json:"messages_last,omitempty"`
MessagesNext *string `json:"messages_next,omitempty"`
MessagesPageDown *string `json:"messages_page_down,omitempty"`
MessagesPageUp *string `json:"messages_page_up,omitempty"`
MessagesPrevious *string `json:"messages_previous,omitempty"`
ModelList *string `json:"model_list,omitempty"`
ProjectInit *string `json:"project_init,omitempty"`
SessionCompact *string `json:"session_compact,omitempty"`
SessionInterrupt *string `json:"session_interrupt,omitempty"`
SessionList *string `json:"session_list,omitempty"`
SessionNew *string `json:"session_new,omitempty"`
SessionShare *string `json:"session_share,omitempty"`
ThemeList *string `json:"theme_list,omitempty"`
ToolDetails *string `json:"tool_details,omitempty"`
}
// ConfigMcpLocal defines model for Config.McpLocal.
type ConfigMcpLocal struct {
Command []string `json:"command"`
Environment *map[string]string `json:"environment,omitempty"`
Type string `json:"type"`
}
// ConfigMcpRemote defines model for Config.McpRemote.
type ConfigMcpRemote struct {
Type string `json:"type"`
Url string `json:"url"`
}
// Error defines model for Error.
type Error struct {
Data map[string]interface{} `json:"data"`
@@ -263,10 +357,10 @@ type MessageToolInvocationToolResult struct {
type ModelInfo struct {
Attachment bool `json:"attachment"`
Cost struct {
Input float32 `json:"input"`
InputCached float32 `json:"inputCached"`
Output float32 `json:"output"`
OutputCached float32 `json:"outputCached"`
CacheRead *float32 `json:"cache_read,omitempty"`
CacheWrite *float32 `json:"cache_write,omitempty"`
Input float32 `json:"input"`
Output float32 `json:"output"`
} `json:"cost"`
Id string `json:"id"`
Limit struct {
@@ -280,6 +374,7 @@ type ModelInfo struct {
// ProviderInfo defines model for Provider.Info.
type ProviderInfo struct {
Api *string `json:"api,omitempty"`
Env []string `json:"env"`
Id string `json:"id"`
Models map[string]ModelInfo `json:"models"`
@@ -320,14 +415,14 @@ type SessionInfo struct {
Id string `json:"id"`
ParentID *string `json:"parentID,omitempty"`
Share *struct {
Secret string `json:"secret"`
Url string `json:"url"`
Url string `json:"url"`
} `json:"share,omitempty"`
Time struct {
Created float32 `json:"created"`
Updated float32 `json:"updated"`
} `json:"time"`
Title string `json:"title"`
Title string `json:"title"`
Version string `json:"version"`
}
// PostFileSearchJSONBody defines parameters for PostFileSearch.
@@ -472,6 +567,95 @@ func (a MessageInfo_Metadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, e
return json.Marshal(object)
}
// AsConfigMcpLocal returns the union data inside the ConfigInfo_Mcp_AdditionalProperties as a ConfigMcpLocal
func (t ConfigInfo_Mcp_AdditionalProperties) AsConfigMcpLocal() (ConfigMcpLocal, error) {
var body ConfigMcpLocal
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromConfigMcpLocal overwrites any union data inside the ConfigInfo_Mcp_AdditionalProperties as the provided ConfigMcpLocal
func (t *ConfigInfo_Mcp_AdditionalProperties) FromConfigMcpLocal(v ConfigMcpLocal) error {
v.Type = "local"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeConfigMcpLocal performs a merge with any union data inside the ConfigInfo_Mcp_AdditionalProperties, using the provided ConfigMcpLocal
func (t *ConfigInfo_Mcp_AdditionalProperties) MergeConfigMcpLocal(v ConfigMcpLocal) error {
v.Type = "local"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsConfigMcpRemote returns the union data inside the ConfigInfo_Mcp_AdditionalProperties as a ConfigMcpRemote
func (t ConfigInfo_Mcp_AdditionalProperties) AsConfigMcpRemote() (ConfigMcpRemote, error) {
var body ConfigMcpRemote
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromConfigMcpRemote overwrites any union data inside the ConfigInfo_Mcp_AdditionalProperties as the provided ConfigMcpRemote
func (t *ConfigInfo_Mcp_AdditionalProperties) FromConfigMcpRemote(v ConfigMcpRemote) error {
v.Type = "remote"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeConfigMcpRemote performs a merge with any union data inside the ConfigInfo_Mcp_AdditionalProperties, using the provided ConfigMcpRemote
func (t *ConfigInfo_Mcp_AdditionalProperties) MergeConfigMcpRemote(v ConfigMcpRemote) error {
v.Type = "remote"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t ConfigInfo_Mcp_AdditionalProperties) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
}
err := json.Unmarshal(t.union, &discriminator)
return discriminator.Discriminator, err
}
func (t ConfigInfo_Mcp_AdditionalProperties) ValueByDiscriminator() (interface{}, error) {
discriminator, err := t.Discriminator()
if err != nil {
return nil, err
}
switch discriminator {
case "local":
return t.AsConfigMcpLocal()
case "remote":
return t.AsConfigMcpRemote()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
}
func (t ConfigInfo_Mcp_AdditionalProperties) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *ConfigInfo_Mcp_AdditionalProperties) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
// AsEventStorageWrite returns the union data inside the Event as a EventStorageWrite
func (t Event) AsEventStorageWrite() (EventStorageWrite, error) {
var body EventStorageWrite
@@ -612,6 +796,34 @@ func (t *Event) MergeEventMessagePartUpdated(v EventMessagePartUpdated) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsEventSessionUpdated returns the union data inside the Event as a EventSessionUpdated
func (t Event) AsEventSessionUpdated() (EventSessionUpdated, error) {
var body EventSessionUpdated
@@ -668,34 +880,6 @@ func (t *Event) MergeEventSessionError(v EventSessionError) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t Event) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@@ -1326,6 +1510,9 @@ type ClientInterface interface {
// PostAppInitialize request
PostAppInitialize(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostConfigGet request
PostConfigGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetEvent request
GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1404,6 +1591,18 @@ func (c *Client) PostAppInitialize(ctx context.Context, reqEditors ...RequestEdi
return c.Client.Do(req)
}
func (c *Client) PostConfigGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostConfigGetRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetEventRequest(c.Server)
if err != nil {
@@ -1698,6 +1897,33 @@ func NewPostAppInitializeRequest(server string) (*http.Request, error) {
return req, nil
}
// NewPostConfigGetRequest generates requests for PostConfigGet
func NewPostConfigGetRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/config_get")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewGetEventRequest generates requests for GetEvent
func NewGetEventRequest(server string) (*http.Request, error) {
var err error
@@ -2189,6 +2415,9 @@ type ClientWithResponsesInterface interface {
// PostAppInitializeWithResponse request
PostAppInitializeWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostAppInitializeResponse, error)
// PostConfigGetWithResponse request
PostConfigGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostConfigGetResponse, error)
// GetEventWithResponse request
GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error)
@@ -2287,6 +2516,28 @@ func (r PostAppInitializeResponse) StatusCode() int {
return 0
}
type PostConfigGetResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *ConfigInfo
}
// Status returns HTTPResponse.Status
func (r PostConfigGetResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostConfigGetResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type GetEventResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -2600,6 +2851,15 @@ func (c *ClientWithResponses) PostAppInitializeWithResponse(ctx context.Context,
return ParsePostAppInitializeResponse(rsp)
}
// PostConfigGetWithResponse request returning *PostConfigGetResponse
func (c *ClientWithResponses) PostConfigGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostConfigGetResponse, error) {
rsp, err := c.PostConfigGet(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostConfigGetResponse(rsp)
}
// GetEventWithResponse request returning *GetEventResponse
func (c *ClientWithResponses) GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error) {
rsp, err := c.GetEvent(ctx, reqEditors...)
@@ -2825,6 +3085,32 @@ func ParsePostAppInitializeResponse(rsp *http.Response) (*PostAppInitializeRespo
return response, nil
}
// ParsePostConfigGetResponse parses an HTTP response from a PostConfigGetWithResponse call
func ParsePostConfigGetResponse(rsp *http.Response) (*PostConfigGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostConfigGetResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ConfigInfo
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseGetEventResponse parses an HTTP response from a GetEventWithResponse call
func ParseGetEventResponse(rsp *http.Response) (*GetEventResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+1
View File
@@ -30,6 +30,7 @@
"toolbeam-docs-theme": "0.3.0"
},
"devDependencies": {
"opencode": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
}
+4 -2
View File
@@ -1,3 +1,5 @@
<svg width="33" height="42" viewBox="0 0 33 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="600" height="600" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 377 B

+1 -1
View File
@@ -10,7 +10,7 @@ const path = Astro.url.pathname;
const links = config.social || [];
---
{ path.startsWith("/share")
{ path.startsWith("/s")
? <div class="header sl-flex">
<div class="title-wrapper sl-flex">
<SiteTitle {...Astro.props} />
File diff suppressed because it is too large Load Diff
@@ -35,3 +35,12 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="currentColor" />
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="currentColor" />
</svg>
)
}
+148 -79
View File
@@ -1,9 +1,14 @@
.root {
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 2.5rem;
line-height: 1;
padding: 1.5rem;
@media (max-width: 30rem) {
padding: 1rem;
gap: 2rem;
}
--sm-tool-width: 28rem;
--md-tool-width: 40rem;
@@ -48,86 +53,35 @@
.header {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1rem;
@media (max-width: 30rem) {
gap: 1rem;
}
[data-section="title"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3rem;
h1 {
font-size: 2.75rem;
font-weight: 500;
line-height: 1.15;
letter-spacing: -0.05em;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
& > div {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
[data-section="date"] {
span {
font-size: 0.875rem;
color: var(--sl-color-text-secondary);
&[data-placeholder] {
color: var(--sl-color-text-dimmed);
}
}
@media (max-width: 30rem) {
font-size: 1.75rem;
line-height: 1.25;
-webkit-line-clamp: 3;
}
p[data-section="status"] {
flex: 0 0 auto;
display: flex;
gap: 0.375rem;
font-size: 0.75rem;
span:first-child {
color: var(--sl-color-divider);
&[data-status="connected"] {
color: var(--sl-color-green);
}
&[data-status="connecting"] {
color: var(--sl-color-orange);
}
&[data-status="disconnected"] {
color: var(--sl-color-divider);
}
&[data-status="reconnecting"] {
color: var(--sl-color-orange);
}
&[data-status="error"] {
color: var(--sl-color-red);
}
}
}
}
}
[data-section="row"] {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
h1 {
font-size: 1.75rem;
font-weight: 500;
line-height: 1.25;
letter-spacing: -0.05em;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
@media (max-width: 30rem) {
font-size: 1.25rem;
-webkit-line-clamp: 3;
}
gap: 0.5rem;
}
[data-section="stats"] {
@@ -150,11 +104,12 @@
}
}
[data-section="stats"][data-section-root],
[data-section="stats"][data-section-models] {
li {
gap: 0.3125rem;
[data-stat-model-icon] {
[data-stat-icon] {
flex: 0 0 auto;
color: var(--sl-color-text-dimmed);
opacity: 0.85;
@@ -168,6 +123,13 @@
}
}
}
[data-section="time"] {
span {
color: var(--sl-color-text-dimmed);
font-size: 0.875rem;
}
}
}
.parts {
@@ -187,14 +149,81 @@
align-items: center;
justify-content: flex-start;
a:first-child {
display: block;
flex: 0 0 auto;
width: 18px;
opacity: 0.65;
svg {
color: var(--sl-color-text-secondary);
[data-element-anchor] {
position: relative;
a:first-child {
display: block;
flex: 0 0 auto;
width: 18px;
opacity: 0.65;
svg {
color: var(--sl-color-text-secondary);
display: block;
&:nth-child(3) {
color: var(--sl-color-green-high);
}
}
svg:nth-child(2),
svg:nth-child(3) {
display: none;
}
&:hover {
svg:nth-child(1) {
display: none;
}
svg:nth-child(2) {
display: block;
}
}
}
[data-element-tooltip] {
position: absolute;
top: 50%;
left: calc(100% + 12px);
transform: translate(0, -50%);
line-height: 1.1;
padding: 0.375em 0.5em calc(0.375em + 2px);
background: var(--sl-color-white);
color: var(--sl-color-text-invert);
font-size: 0.6875rem;
border-radius: 7px;
white-space: nowrap;
z-index: 1;
opacity: 0;
visibility: hidden;
&::after {
content: "";
position: absolute;
top: 50%;
left: -15px;
transform: translateY(-50%);
border: 8px solid transparent;
border-right-color: var(--sl-color-white);
}
}
&[data-status="copied"] {
[data-element-tooltip] {
opacity: 1;
visibility: visible;
}
a,
a:hover {
svg:nth-child(1),
svg:nth-child(2) {
display: none;
}
svg:nth-child(3) {
display: block;
}
}
}
}
@@ -346,6 +375,44 @@
}
}
}
[data-part-type="connection-status"] {
& > [data-section="decoration"] {
span:first-child {
flex: 0 0 auto;
display: block;
margin: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background-color: var(--sl-color-divider);
&[data-status="connected"] {
background-color: var(--sl-color-green);
}
&[data-status="connecting"] {
background-color: var(--sl-color-orange);
}
&[data-status="disconnected"] {
background-color: var(--sl-color-divider);
}
&[data-status="reconnecting"] {
background-color: var(--sl-color-orange);
}
&[data-status="error"] {
background-color: var(--sl-color-red);
}
}
}
& > [data-section="content"] {
span {
display: block;
line-height: 18px;
font-size: 0.875rem;
color: var(--sl-color-text-dimmed);
}
}
}
}
.message-text {
@@ -433,7 +500,7 @@
max-width: var(--md-tool-width);
[data-section="content"] {
p {
pre {
margin-bottom: 0.5rem;
line-height: 1.5;
font-size: 0.75rem;
@@ -493,12 +560,13 @@
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
width: 100%;
max-width: var(--sm-tool-width);
& > [data-section="body"] {
width: var(--sm-tool-width);
width: 100%;
border: 1px solid var(--sl-color-divider);
border-radius: 0.25rem;
max-width: 100%;
[data-section="header"] {
position: relative;
@@ -616,7 +684,8 @@
list-style-type: none;
padding: 0;
margin: 0;
width: var(--sm-tool-width);
width: 100%;
max-width: var(--sm-tool-width);
border: 1px solid var(--sl-color-divider);
border-radius: 0.25rem;
+5 -1
View File
@@ -13,7 +13,7 @@ const data = await res.json();
let cost = 0;
const models: Set<string> = new Set();
const version = "v0.1.1";
const version = `v${data.info.version}`;
Object.values(data.messages).forEach((d) => {
const assistant = d.metadata?.assistant;
if (assistant) {
@@ -74,7 +74,11 @@ const ogImage = `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png
body > .page > .main-frame .main-pane > main > .content-panel:first-of-type {
display: none;
}
body > .page > .main-frame .main-pane > main {
padding: 0;
}
body > .page > .main-frame .main-pane > main > .content-panel + .content-panel {
border-top: none !important;
padding: 0;
}
</style>
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
if [ ! -d ".git" ]; then
exit 0
fi
mkdir -p .git/hooks
cat > .git/hooks/pre-push << 'EOF'
#!/bin/sh
bun run typecheck
EOF
chmod +x .git/hooks/pre-push
echo "✅ Pre-push hook installed"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -e
goreleaser build --clean --snapshot --skip validate