Compare commits

...

37 Commits

Author SHA1 Message Date
Dax Raad 6e375bef0d docs: modes 2025-07-10 11:53:28 -04:00
Dax Raad 67106a6967 docs: add config variable docs 2025-07-10 11:48:55 -04:00
Dax Raad b5d690620d support env and file pointers in config 2025-07-10 11:45:31 -04:00
Dax Raad 9db3ce1d0b opencode run respects mode 2025-07-10 11:28:28 -04:00
Dax Raad 1cc55b68ef wip: scrap 2025-07-10 11:25:37 -04:00
Dax Raad 469f667774 set max output token limit to 32_000 2025-07-10 11:25:37 -04:00
adamdottv 6603d9a9f0 feat: --mode flag passed to tui 2025-07-10 10:19:25 -05:00
adamdottv 5dc1920a4c feat: mode flag in cli run command 2025-07-10 10:13:15 -05:00
adamdottv d3e5f3f3a8 feat(tui): add token and cost info to session header 2025-07-10 10:06:51 -05:00
adamdottv ce4cb820f7 feat(tui): modes 2025-07-10 10:06:51 -05:00
Dax Raad ba5be6b625 make LSP lazy again 2025-07-10 09:37:40 -04:00
adamdottv f95c3f4177 fix(tui): fouc in textarea on app load 2025-07-10 08:20:17 -05:00
adamdottv d2b1307bff fix(tui): textarea cursor sync issues with attachments 2025-07-10 07:49:36 -05:00
adamdottv b40ba32adc fix(tui): textarea issues 2025-07-10 07:38:57 -05:00
GitHub Action ce0cebb7d7 ignore: update download stats 2025-07-10 2025-07-10 12:04:15 +00:00
Dax Raad f478f89a68 temporary grok 4 patch 2025-07-10 07:57:55 -04:00
Dax Raad 85d95f0f2b disable lsp on non-git folders 2025-07-10 07:39:02 -04:00
Dax Raad 1515efc77c fix session is busy error 2025-07-10 07:27:03 -04:00
Josh Medeski 6d393759e1 feat(tui): subsitute cwd home path on status bar (#808) 2025-07-10 06:12:19 -05:00
Adi Yeroslav a1701678cd feat(tui): /editor - change the auto-send behavior to put content in input box instead (#827) 2025-07-10 05:57:52 -05:00
Timo Clasen c411a26d6f feat(tui): hide cost if using subscription model (#828) 2025-07-10 05:56:36 -05:00
adamdottv 85dbfeb314 feat(tui): @symbol attachments 2025-07-10 05:53:00 -05:00
Dax Raad 085c0e4e2b respect go.work when spawning LSP 2025-07-09 22:54:47 -04:00
Dax Raad 8404a97c3e better detection of prettier formatter 2025-07-09 22:37:31 -04:00
Dax Raad 0ee3b1ede2 do not wait for LSP to be fully ready 2025-07-09 21:59:38 -04:00
Dax Raad a826936702 modes concept 2025-07-09 21:59:38 -04:00
Jay V fd4a5d5a63 docs: share doc edit 2025-07-09 20:26:31 -04:00
Jay V 69cf1d7b7e docs: share doc 2025-07-09 20:24:09 -04:00
Jay V 8e0a1d1167 docs: edit troubleshooting 2025-07-09 19:55:14 -04:00
Timo Clasen f22021187d feat(tui): treat pasted text file paths as file references (#809) 2025-07-09 18:37:39 -05:00
Jay V febecc348a docs: enterprise doc 2025-07-09 15:46:57 -04:00
Jay V c5ccfc3e94 docs: share page last part fix 2025-07-09 15:46:57 -04:00
Mike Wallio 1f6efc6b94 Add gpt-4.1 beast prompt (#778)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-09 12:11:54 -04:00
Frank Denis 727fe6f942 LSP: fix SimpleRoots to actually search in the root directory (#795) 2025-07-09 10:35:06 -05:00
Dax Raad a91e79382e ci: remove checked in config.schema.json 2025-07-09 11:30:42 -04:00
Dax Raad 5c626e0a2f ci: generate config schema as part of build 2025-07-09 11:25:58 -04:00
adamdottv 8e9e383219 chore: troubleshooting docs 2025-07-09 10:12:36 -05:00
59 changed files with 2257 additions and 1081 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
+12 -11
View File
@@ -1,13 +1,14 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | ----------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
+2 -2
View File
@@ -83,7 +83,7 @@
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.4.1",
"toolbeam-docs-theme": "0.4.3",
},
"devDependencies": {
"@types/node": "catalog:",
@@ -1531,7 +1531,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.3", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-3um/NsSq4xFeKbKrNGPHIzfTixwnEVvroqA8Q+lecnYHHJ5TtiYTggHDqewOW+I67t0J1IVBwVKUPjxiQfIcog=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
-556
View File
@@ -1,556 +0,0 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string",
"default": "ctrl+x",
"description": "Leader key for keybind combinations\n\ndefault: `ctrl+x`",
"examples": [
"ctrl+x"
]
},
"app_help": {
"type": "string",
"default": "<leader>h",
"description": "Show help dialog\n\ndefault: `<leader>h`",
"examples": [
"<leader>h"
]
},
"editor_open": {
"type": "string",
"default": "<leader>e",
"description": "Open external editor\n\ndefault: `<leader>e`",
"examples": [
"<leader>e"
]
},
"session_new": {
"type": "string",
"default": "<leader>n",
"description": "Create a new session\n\ndefault: `<leader>n`",
"examples": [
"<leader>n"
]
},
"session_list": {
"type": "string",
"default": "<leader>l",
"description": "List all sessions\n\ndefault: `<leader>l`",
"examples": [
"<leader>l"
]
},
"session_share": {
"type": "string",
"default": "<leader>s",
"description": "Share current session\n\ndefault: `<leader>s`",
"examples": [
"<leader>s"
]
},
"session_unshare": {
"type": "string",
"default": "<leader>u",
"description": "Unshare current session\n\ndefault: `<leader>u`",
"examples": [
"<leader>u"
]
},
"session_interrupt": {
"type": "string",
"default": "esc",
"description": "Interrupt current session\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"session_compact": {
"type": "string",
"default": "<leader>c",
"description": "Compact the session\n\ndefault: `<leader>c`",
"examples": [
"<leader>c"
]
},
"tool_details": {
"type": "string",
"default": "<leader>d",
"description": "Toggle tool details\n\ndefault: `<leader>d`",
"examples": [
"<leader>d"
]
},
"model_list": {
"type": "string",
"default": "<leader>m",
"description": "List available models\n\ndefault: `<leader>m`",
"examples": [
"<leader>m"
]
},
"theme_list": {
"type": "string",
"default": "<leader>t",
"description": "List available themes\n\ndefault: `<leader>t`",
"examples": [
"<leader>t"
]
},
"file_list": {
"type": "string",
"default": "<leader>f",
"description": "List files\n\ndefault: `<leader>f`",
"examples": [
"<leader>f"
]
},
"file_close": {
"type": "string",
"default": "esc",
"description": "Close file\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"file_search": {
"type": "string",
"default": "<leader>/",
"description": "Search file\n\ndefault: `<leader>/`",
"examples": [
"<leader>/"
]
},
"file_diff_toggle": {
"type": "string",
"default": "<leader>v",
"description": "Split/unified diff\n\ndefault: `<leader>v`",
"examples": [
"<leader>v"
]
},
"project_init": {
"type": "string",
"default": "<leader>i",
"description": "Create/update AGENTS.md\n\ndefault: `<leader>i`",
"examples": [
"<leader>i"
]
},
"input_clear": {
"type": "string",
"default": "ctrl+c",
"description": "Clear input field\n\ndefault: `ctrl+c`",
"examples": [
"ctrl+c"
]
},
"input_paste": {
"type": "string",
"default": "ctrl+v",
"description": "Paste from clipboard\n\ndefault: `ctrl+v`",
"examples": [
"ctrl+v"
]
},
"input_submit": {
"type": "string",
"default": "enter",
"description": "Submit input\n\ndefault: `enter`",
"examples": [
"enter"
]
},
"input_newline": {
"type": "string",
"default": "shift+enter,ctrl+j",
"description": "Insert newline in input\n\ndefault: `shift+enter,ctrl+j`",
"examples": [
"shift+enter,ctrl+j"
]
},
"messages_page_up": {
"type": "string",
"default": "pgup",
"description": "Scroll messages up by one page\n\ndefault: `pgup`",
"examples": [
"pgup"
]
},
"messages_page_down": {
"type": "string",
"default": "pgdown",
"description": "Scroll messages down by one page\n\ndefault: `pgdown`",
"examples": [
"pgdown"
]
},
"messages_half_page_up": {
"type": "string",
"default": "ctrl+alt+u",
"description": "Scroll messages up by half page\n\ndefault: `ctrl+alt+u`",
"examples": [
"ctrl+alt+u"
]
},
"messages_half_page_down": {
"type": "string",
"default": "ctrl+alt+d",
"description": "Scroll messages down by half page\n\ndefault: `ctrl+alt+d`",
"examples": [
"ctrl+alt+d"
]
},
"messages_previous": {
"type": "string",
"default": "ctrl+up",
"description": "Navigate to previous message\n\ndefault: `ctrl+up`",
"examples": [
"ctrl+up"
]
},
"messages_next": {
"type": "string",
"default": "ctrl+down",
"description": "Navigate to next message\n\ndefault: `ctrl+down`",
"examples": [
"ctrl+down"
]
},
"messages_first": {
"type": "string",
"default": "ctrl+g",
"description": "Navigate to first message\n\ndefault: `ctrl+g`",
"examples": [
"ctrl+g"
]
},
"messages_last": {
"type": "string",
"default": "ctrl+alt+g",
"description": "Navigate to last message\n\ndefault: `ctrl+alt+g`",
"examples": [
"ctrl+alt+g"
]
},
"messages_layout_toggle": {
"type": "string",
"default": "<leader>p",
"description": "Toggle layout\n\ndefault: `<leader>p`",
"examples": [
"<leader>p"
]
},
"messages_copy": {
"type": "string",
"default": "<leader>y",
"description": "Copy message\n\ndefault: `<leader>y`",
"examples": [
"<leader>y"
]
},
"messages_revert": {
"type": "string",
"default": "<leader>r",
"description": "Revert message\n\ndefault: `<leader>r`",
"examples": [
"<leader>r"
]
},
"app_exit": {
"type": "string",
"default": "ctrl+c,<leader>q",
"description": "Exit the application\n\ndefault: `ctrl+c,<leader>q`",
"examples": [
"ctrl+c,<leader>q"
]
}
},
"additionalProperties": false,
"description": "Custom keybind configurations"
},
"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"
},
"log_level": {
"type": "string",
"enum": [
"DEBUG",
"INFO",
"WARN",
"ERROR"
],
"description": "Minimum log level to write to log files"
},
"provider": {
"type": "object",
"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": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output"
],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
],
"additionalProperties": false
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
},
"mcp": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local",
"description": "Type of MCP server connection"
},
"command": {
"type": "array",
"items": {
"type": "string"
},
"description": "Command and arguments to run the MCP server"
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Environment variables to set when running the MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote",
"description": "Type of MCP server connection"
},
"url": {
"type": "string",
"description": "URL of the remote MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Additional instruction files or patterns to include"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+3 -1
View File
@@ -4,6 +4,8 @@ import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const file = process.argv[2]
const result = zodToJsonSchema(Config.Info, {
/**
* We'll use the `default` values of the field as the only value in `examples`.
@@ -30,4 +32,4 @@ const result = zodToJsonSchema(Config.Info, {
return jsonSchema
},
})
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
await Bun.write(file, JSON.stringify(result, null, 2))
@@ -3,6 +3,7 @@ import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
@@ -12,6 +13,7 @@ export const DebugCommand = cmd({
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(ScrapCommand)
.command(SnapshotCommand)
.command({
command: "wait",
+14 -1
View File
@@ -5,7 +5,8 @@ import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
async handler() {},
})
@@ -31,3 +32,15 @@ export const SymbolsCommand = cmd({
})
},
})
export const DocumentSymbolsCommand = cmd({
command: "document-symbols <uri>",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri)
console.log(JSON.stringify(results, null, 2))
})
},
})
@@ -0,0 +1,7 @@
import { cmd } from "../cmd"
export const ScrapCommand = cmd({
command: "scrap",
builder: (yargs) => yargs,
async handler() {},
})
+14 -2
View File
@@ -8,6 +8,7 @@ import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
import { Mode } from "../../session/mode"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -52,6 +53,10 @@ export const RunCommand = cmd({
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("mode", {
type: "string",
describe: "mode to use",
})
},
handler: async (args) => {
let message = args.message.join(" ")
@@ -139,10 +144,17 @@ export const RunCommand = cmd({
UI.error(err)
})
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
...(mode.model
? mode.model
: {
providerID,
modelID,
}),
mode: mode.name,
parts: [
{
type: "text",
+7
View File
@@ -11,6 +11,7 @@ import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
import { Mode } from "../../session/mode"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -30,6 +31,10 @@ export const TuiCommand = cmd({
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("mode", {
type: "string",
describe: "mode to use",
}),
handler: async (args) => {
while (true) {
@@ -77,6 +82,7 @@ export const TuiCommand = cmd({
...cmd,
...(args.model ? ["--model", args.model] : []),
...(args.prompt ? ["--prompt", args.prompt] : []),
...(args.mode ? ["--mode", args.mode] : []),
],
cwd,
stdout: "inherit",
@@ -87,6 +93,7 @@ export const TuiCommand = cmd({
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
OPENCODE_MODES: JSON.stringify(await Mode.list()),
},
onExit: () => {
server.stop()
+48 -7
View File
@@ -55,10 +55,22 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Mode = z
.object({
model: z.string().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
})
.openapi({
ref: "ModeConfig",
})
export type Mode = z.infer<typeof Mode>
export const Keybinds = z
.object({
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
switch_mode: z.string().optional().default("tab").describe("Switch mode"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
@@ -99,6 +111,7 @@ export namespace Config {
.openapi({
ref: "KeybindsConfig",
})
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
@@ -108,6 +121,13 @@ export namespace Config {
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(),
mode: z
.object({
build: Mode.optional(),
plan: Mode.optional(),
})
.catchall(Mode)
.optional(),
log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
provider: z
.record(
@@ -175,19 +195,40 @@ export namespace Config {
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
async function load(configPath: string) {
let text = await Bun.file(configPath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: configPath }, { cause: err })
})
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = text.match(/\{file:([^}]+)\}/g)
if (fileMatches) {
const configDir = path.dirname(configPath)
for (const match of fileMatches) {
const filePath = match.slice(6, -1)
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = await Bun.file(resolvedPath).text()
text = text.replace(match, fileContent)
}
}
let data: any
try {
data = JSON.parse(text)
} catch (err) {
throw new JsonError({ path: configPath }, { cause: err as Error })
}
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
+8 -17
View File
@@ -1,5 +1,7 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
import { Filesystem } from "../util/filesystem"
import path from "path"
export interface Info {
name: string
@@ -29,7 +31,7 @@ export const mix: Info = {
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -62,23 +64,12 @@ export const prettier: Info = {
".gql",
],
async enabled() {
// this is more complicated because we only want to use prettier if it's
// being used with the current project
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
const app = App.info()
const nms = await Filesystem.findUp("node_modules", app.path.cwd, app.path.root)
for (const item of nms) {
if (await Bun.file(path.join(item, ".bin", "prettier")).exists()) return true
}
return false
},
}
-3
View File
@@ -184,9 +184,6 @@ export namespace LSPClient {
},
}
if (input.server.onInitialized) {
await input.server.onInitialized(result)
}
l.info("initialized")
return result
+122 -44
View File
@@ -4,27 +4,33 @@ import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Range = z
.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
})
.openapi({
ref: "Range",
})
export type Range = z.infer<typeof Range>
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
range: Range,
}),
})
.openapi({
@@ -32,38 +38,25 @@ export namespace LSP {
})
export type Symbol = z.infer<typeof Symbol>
export const DocumentSymbol = z
.object({
name: z.string(),
detail: z.string().optional(),
kind: z.number(),
range: Range,
selectionRange: Range,
})
.openapi({
ref: "DocumentSymbol",
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = App.state(
"lsp",
async (app) => {
log.info("initializing")
async () => {
const clients: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
const roots = await server.roots(app)
for (const root of roots) {
if (!Filesystem.overlaps(app.path.cwd, root)) continue
log.info("", {
root,
serverID: server.id,
})
const handle = await server.spawn(App.info(), root)
if (!handle) break
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
handle.process.kill()
log.error("", { error: err })
})
if (!client) break
clients.push(client)
}
}
log.info("initialized")
return {
broken: new Set<string>(),
clients,
}
},
@@ -78,13 +71,43 @@ export namespace LSP {
return state()
}
async function getClients(file: string) {
const s = await state()
const extension = path.parse(file).ext
const result: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
if (!server.extensions.includes(extension)) continue
const root = await server.root(file, App.info())
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const handle = await server.spawn(App.info(), root)
if (!handle) continue
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
s.broken.add(root + server.id)
handle.process.kill()
log.error("", { error: err })
})
if (!client) continue
s.clients.push(client)
result.push(client)
}
return result
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
const clients = await getClients(input)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
if (!clients.includes(client)) return
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
@@ -117,17 +140,72 @@ export namespace LSP {
})
}
enum SymbolKind {
File = 1,
Module = 2,
Namespace = 3,
Package = 4,
Class = 5,
Method = 6,
Property = 7,
Field = 8,
Constructor = 9,
Enum = 10,
Interface = 11,
Function = 12,
Variable = 13,
Constant = 14,
String = 15,
Number = 16,
Boolean = 17,
Array = 18,
Object = 19,
Key = 20,
Null = 21,
EnumMember = 22,
Struct = 23,
Event = 24,
Operator = 25,
TypeParameter = 26,
}
const kinds = [
SymbolKind.Class,
SymbolKind.Function,
SymbolKind.Method,
SymbolKind.Interface,
SymbolKind.Variable,
SymbolKind.Constant,
SymbolKind.Struct,
SymbolKind.Enum,
]
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
export async function documentSymbol(uri: string) {
return run((client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {
textDocument: {
uri,
},
})
.catch(() => []),
)
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
.then((result) => result.filter(Boolean))
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
+127 -44
View File
@@ -6,10 +6,7 @@ import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { unique } from "remeda"
import { Ripgrep } from "../file/ripgrep"
import type { LSPClient } from "./client"
import { withTimeout } from "../util/timeout"
import { Filesystem } from "../util/filesystem"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -17,20 +14,21 @@ export namespace LSPServer {
export interface Handle {
process: ChildProcessWithoutNullStreams
initialization?: Record<string, any>
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
}
type RootsFunction = (app: App.Info) => Promise<string[]>
type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
const SimpleRoots = (patterns: string[]): RootsFunction => {
return async (app) => {
const glob = `**/*/{${patterns.join(",")}}`
const files = await Ripgrep.files({
glob: [glob],
cwd: app.path.root,
const NearestRoot = (patterns: string[]): RootFunction => {
return async (file, app) => {
const files = Filesystem.up({
targets: patterns,
start: path.dirname(file),
stop: app.path.root,
})
const dirs = files.map((file) => path.dirname(file))
return unique(dirs).map((dir) => path.join(app.path.root, dir))
const first = await files.next()
await files.return()
if (!first.value) return app.path.root
return path.dirname(first.value)
}
}
@@ -38,13 +36,13 @@ export namespace LSPServer {
id: string
extensions: string[]
global?: boolean
roots: (app: App.Info) => Promise<string[]>
root: RootFunction
spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
roots: async (app) => [app.path.root],
root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
@@ -63,30 +61,17 @@ export namespace LSPServer {
path: tsserver,
},
},
// tsserver sucks and won't start processing codebase until you open a file
onInitialized: async (lsp) => {
const [hint] = await Ripgrep.files({
cwd: lsp.root,
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
limit: 1,
})
const wait = new Promise<void>(async (resolve) => {
const notif = lsp.connection.onNotification("$/progress", (params) => {
if (params.value.kind !== "end") return
notif.dispose()
resolve()
})
await lsp.notify.open({ path: path.join(lsp.root, hint) })
})
await withTimeout(wait, 5_000)
},
}
},
}
export const Gopls: Info = {
id: "golang",
roots: SimpleRoots(["go.mod", "go.sum"]),
root: async (file, app) => {
const work = await NearestRoot(["go.work"])(file, app)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file, app)
},
extensions: [".go"],
async spawn(_, root) {
let bin = Bun.which("gopls", {
@@ -122,7 +107,7 @@ export namespace LSPServer {
export const RubyLsp: Info = {
id: "ruby-lsp",
roots: SimpleRoots(["Gemfile"]),
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
@@ -163,14 +148,7 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
roots: SimpleRoots([
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"pyrightconfig.json",
]),
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(_, root) {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
cwd: root,
@@ -188,7 +166,7 @@ export namespace LSPServer {
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
roots: SimpleRoots(["mix.exs", "mix.lock"]),
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
@@ -239,4 +217,109 @@ export namespace LSPServer {
}
},
}
export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(_, root) {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
}
log.info("downloading zls from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch zls release info")
return
}
const release = await releaseResponse.json()
const platform = process.platform
const arch = process.arch
let assetName = ""
let zlsArch: string = arch
if (arch === "arm64") zlsArch = "aarch64"
else if (arch === "x64") zlsArch = "x86_64"
else if (arch === "ia32") zlsArch = "x86"
let zlsPlatform: string = platform
if (platform === "darwin") zlsPlatform = "macos"
else if (platform === "win32") zlsPlatform = "windows"
const ext = platform === "win32" ? "zip" : "tar.xz"
assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
const supportedCombos = [
"zls-x86_64-linux.tar.xz",
"zls-x86_64-macos.tar.xz",
"zls-x86_64-windows.zip",
"zls-aarch64-linux.tar.xz",
"zls-aarch64-macos.tar.xz",
"zls-aarch64-windows.zip",
"zls-x86-linux.tar.xz",
"zls-x86-windows.zip",
]
if (!supportedCombos.includes(assetName)) {
log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`)
return
}
const asset = release.assets.find((a: any) => a.name === assetName)
if (!asset) {
log.error(`Could not find asset ${assetName} in latest zls release`)
return
}
const downloadUrl = asset.browser_download_url
const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) {
log.error("Failed to download zls")
return
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (ext === "zip") {
await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
} else {
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
}
await fs.rm(tempPath, { force: true })
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
log.error("Failed to extract zls binary")
return
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.nothrow()
}
log.info(`installed zls`, { bin })
}
return {
process: spawn(bin, {
cwd: root,
}),
}
},
}
}
+22
View File
@@ -16,6 +16,7 @@ import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
import { Mode } from "../session/mode"
const ERRORS = {
400: {
@@ -447,6 +448,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
mode: z.string(),
parts: MessageV2.UserPart.array(),
}),
),
@@ -681,6 +683,26 @@ export namespace Server {
return c.json(true)
},
)
.get(
"/mode",
describeRoute({
description: "List all modes",
responses: {
200: {
description: "List of modes",
content: {
"application/json": {
schema: resolver(Mode.Info.array()),
},
},
},
},
}),
async (c) => {
const modes = await Mode.list()
return c.json(modes)
},
)
return result
}
+78 -21
View File
@@ -15,6 +15,7 @@ import {
} from "ai"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
@@ -29,16 +30,20 @@ import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { Storage } from "../storage/storage"
import type { Tool } from "../tool/tool"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { SystemPrompt } from "./system"
import { FileTime } from "../file/time"
import { MessageV2 } from "./message-v2"
import { Mode } from "./mode"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
export namespace Session {
const log = Log.create({ service: "session" })
const OUTPUT_TOKEN_MAX = 32_000
export const Info = z
.object({
id: Identifier.schema("session"),
@@ -281,13 +286,12 @@ export namespace Session {
sessionID: string
providerID: string
modelID: string
mode?: string
parts: MessageV2.UserPart[]
system?: string[]
tools?: Tool.Info[]
}) {
using abort = lock(input.sessionID)
const l = log.clone().tag("session", input.sessionID)
l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
@@ -317,15 +321,13 @@ export namespace Session {
}
const previous = msgs.at(-1) as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX)
// auto summarize if too long
if (previous) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (
model.info.limit.context &&
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
) {
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
@@ -335,6 +337,8 @@ export namespace Session {
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
@@ -345,34 +349,73 @@ export namespace Session {
const url = new URL(part.url)
switch (url.protocol) {
case "file:":
const filepath = path.join(app.path.cwd, url.pathname)
let file = Bun.file(filepath)
// have to normalize, symbol search returns absolute paths
const relativePath = url.pathname.replace(app.path.cwd, ".")
const filePath = path.join(app.path.cwd, relativePath)
if (part.mime === "text/plain") {
let text = await file.text()
let offset: number | undefined = undefined
let limit: number | undefined = undefined
const range = {
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
}
if (range.start != null && part.mime === "text/plain") {
const lines = text.split("\n")
const start = parseInt(range.start)
const end = range.end ? parseInt(range.end) : lines.length
text = lines.slice(start, end).join("\n")
if (range.start != null) {
const filePath = part.url.split("?")[0]
let start = parseInt(range.start)
let end = range.end ? parseInt(range.end) : undefined
// some LSP servers (eg, gopls) don't give full range in
// workspace/symbol searches, so we'll try to find the
// symbol in the document to get the full range
if (start === end) {
const symbols = await LSP.documentSymbol(filePath)
for (const symbol of symbols) {
let range: LSP.Range | undefined
if ("range" in symbol) {
range = symbol.range
} else if ("location" in symbol) {
range = symbol.location.range
}
if (range?.start?.line && range?.start?.line === start) {
start = range.start.line
end = range?.end?.line ?? start
break
}
}
offset = Math.max(start - 2, 0)
if (end) {
limit = end - offset + 2
}
}
}
FileTime.read(input.sessionID, filepath)
const args = { filePath, offset, limit }
const result = await ReadTool.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: "", // read tool doesn't use message ID
metadata: async () => {},
})
return [
{
type: "text",
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
synthetic: true,
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
type: "text",
synthetic: true,
text: result.output,
},
]
}
let file = Bun.file(filePath)
FileTime.read(input.sessionID, filePath)
return [
{
type: "text",
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
synthetic: true,
},
{
type: "file",
@@ -386,6 +429,14 @@ export namespace Session {
return [part]
}),
).then((x) => x.flat())
if (input.mode === "plan")
input.parts.push({
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
if (msgs.length === 0 && !session.parentID) {
generateText({
maxOutputTokens: input.providerID === "google" ? 1024 : 20,
@@ -431,9 +482,13 @@ export namespace Session {
await updateMessage(msg)
msgs.push(msg)
const system = input.system ?? SystemPrompt.provider(input.providerID)
const mode = await Mode.get(input.mode ?? "build")
let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID)
system.push(...(await SystemPrompt.environment()))
system.push(...(await SystemPrompt.custom()))
// max 2 system prompt messages for caching purposes
const [first, ...rest] = system
system = [first, rest.join("\n")]
const next: MessageV2.Info = {
id: Identifier.ascending("message"),
@@ -462,7 +517,8 @@ export namespace Session {
const tools: Record<string, AITool> = {}
for (const item of await Provider.tools(input.providerID)) {
tools[item.id.replaceAll(".", "_")] = tool({
if (mode.tools[item.id] === false) continue
tools[item.id] = tool({
id: item.id as any,
description: item.description,
inputSchema: item.parameters as ZodSchema,
@@ -494,6 +550,7 @@ export namespace Session {
}
for (const [key, item] of Object.entries(await MCP.tools())) {
if (mode.tools[key] === false) continue
const execute = item.execute
if (!execute) continue
item.execute = async (args, opts) => {
@@ -523,7 +580,7 @@ export namespace Session {
const result = streamText({
onError() {},
maxRetries: 10,
maxOutputTokens: Math.max(0, model.info.limit.output) || undefined,
maxOutputTokens: outputLimit,
abortSignal: abort.signal,
stopWhen: stepCountIs(1000),
providerOptions: model.info.options,
@@ -76,6 +76,7 @@ export namespace MessageV2 {
.object({
type: z.literal("text"),
text: z.string(),
synthetic: z.boolean().optional(),
})
.openapi({
ref: "TextPart",
+70
View File
@@ -0,0 +1,70 @@
import { mergeDeep } from "remeda"
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
export namespace Mode {
export const Info = z
.object({
name: z.string(),
model: z
.object({
modelID: z.string(),
providerID: z.string(),
})
.optional(),
prompt: z.string().optional(),
tools: z.record(z.boolean()),
})
.openapi({
ref: "Mode",
})
export type Info = z.infer<typeof Info>
const state = App.state("mode", async () => {
const cfg = await Config.get()
const mode = mergeDeep(
{
build: {},
plan: {
tools: {
write: false,
edit: false,
patch: false,
bash: false,
},
},
},
cfg.mode ?? {},
)
const result: Record<string, Info> = {}
for (const [key, value] of Object.entries(mode)) {
let item = result[key]
if (!item)
item = result[key] = {
name: key,
tools: {},
}
const model = value.model ?? cfg.model
if (model) {
const [providerID, ...rest] = model.split("/")
const modelID = rest.join("/")
item.model = {
modelID,
providerID,
}
}
if (value.prompt) item.prompt = await Bun.file(value.prompt).text()
if (value.tools) item.tools = value.tools
}
return result
})
export async function get(mode: string) {
return state().then((x) => x[mode])
}
export async function list() {
return state().then((x) => Object.values(x))
}
}
@@ -0,0 +1,95 @@
You are an agent known as opencode - please keep going until the users query is completely resolved, before ending your turn and yielding back to the user.
Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
You MUST iterate and keep going until the problem is solved.
I want you to fully solve this autonomously before coming back to me.
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
# Workflow
1. Understand the problem deeply. Carefully read the issue and think critically about what is required.
2. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
3. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using standard markdown format. Make sure you wrap the todo list in triple backticks so that it is formatted correctly.
4. Implement the fix incrementally. Make small, testable code changes.
5. Debug as needed. Use debugging techniques to isolate and resolve issues.
6. Test frequently. Run tests after each change to verify correctness.
7. Iterate until the root cause is fixed and all tests pass.
8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
Refer to the detailed sections below for more information on each step.
## 1. Deeply Understand the Problem
Carefully read the issue and think hard about a plan to solve it before coding.
## 2. Codebase Investigation
- Explore relevant files and directories.
- Search for key functions, classes, or variables related to the issue.
- Read and understand relevant code snippets.
- Identify the root cause of the problem.
- Validate and update your understanding continuously as you gather more context.
## 3. Fetch Provided URLs
- If the user provides a URL, use the `functions.fetch_webpage` tool to retrieve the content of the provided URL.
- After fetching, review the content returned by the fetch tool.
- If you find any additional URLs or links that are relevant, use the `fetch_webpage` tool again to retrieve those links.
- Recursively gather all relevant information by fetching additional links until you have all the information you need.
## 4. Develop a Detailed Plan
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
- Create a todo list in markdown format to track your progress.
- Each time you complete a step, check it off using `[x]` syntax.
- Each time you check off a step, display the updated todo list to the user.
- Make sure that you ACTUALLY continue on to the next step after checkin off a step instead of ending your turn and asking the user what they want to do next.
## 5. Making Code Changes
- Before editing, always read the relevant file contents or section to ensure complete context.
- Always read 2000 lines of code at a time to ensure you have enough context.
- If a patch is not applied correctly, attempt to reapply it.
- Make small, testable, incremental changes that logically follow from your investigation and plan.
## 6. Debugging
- Make code changes only if you have high confidence they can solve the problem
- When debugging, try to determine the root cause rather than addressing symptoms
- Debug for as long as needed to identify the root cause and identify a fix
- Use the #problems tool to check for any problems in the code
- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening
- To test hypotheses, you can also add test statements or functions
- Revisit your assumptions if unexpected behavior occurs.
# Fetch Webpage
Use the `webfetch` tool when the user provides a URL. Follow these steps exactly.
1. Use the `webfetch` tool to retrieve the content of the provided URL.
2. After fetching, review the content returned by the fetch tool.
3. If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.
4. Go back to step 2 and repeat until you have all the information you need.
IMPORTANT: Recursively fetching links is crucial. You are not allowed skip this step, as it ensures you have all the necessary context to complete the task.
# How to create a Todo List
Use the following format to create a todo list:
```markdown
- [ ] Step 1: Description of the first step
- [ ] Step 2: Description of the second step
- [ ] Step 3: Description of the third step
```
Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above.
# Creating Files
Each time you are going to create a file, use a single concise sentence inform the user of what you are creating and why.
# Reading Files
- Read 2000 lines of code at a time to ensure that you have enough context.
- Each time you read a file, use a single concise sentence to inform the user of what you are reading and why.
@@ -0,0 +1,3 @@
<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits).
</system-reminder>
+5 -12
View File
@@ -7,23 +7,16 @@ import path from "path"
import os from "os"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
import PROMPT_TITLE from "./prompt/title.txt"
export namespace SystemPrompt {
export function provider(providerID: string) {
const result = []
switch (providerID) {
case "anthropic":
result.push(PROMPT_ANTHROPIC_SPOOF.trim())
result.push(PROMPT_ANTHROPIC)
break
default:
result.push(PROMPT_ANTHROPIC)
break
}
return result
export function provider(providerID: string, modelID: string) {
if (providerID === "anthropic") return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_ANTHROPIC]
if (modelID.includes("gpt-")) return [PROMPT_BEAST]
return [PROMPT_ANTHROPIC]
}
export async function environment() {
+2
View File
@@ -16,6 +16,8 @@ export const IGNORE_PATTERNS = [
"obj/",
".idea/",
".vscode/",
".zig-cache/",
"zig-out",
]
const LIMIT = 100
+1 -1
View File
@@ -71,7 +71,7 @@ export const ReadTool = Tool.define({
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, false)
LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
+15
View File
@@ -26,6 +26,21 @@ export namespace Filesystem {
return result
}
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
const { targets, start, stop } = options
let current = start
while (true) {
for (const target of targets) {
const search = join(current, target)
if (await exists(search)) yield search
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
+11 -2
View File
@@ -27,6 +27,7 @@ func main() {
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
var mode *string = flag.String("mode", "", "mode to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
@@ -39,6 +40,14 @@ func main() {
os.Exit(1)
}
modesStr := os.Getenv("OPENCODE_MODES")
var modes []opencode.Mode
err = json.Unmarshal([]byte(modesStr), &modes)
if err != nil {
slog.Error("Failed to unmarshal modes", "error", err)
os.Exit(1)
}
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
@@ -47,7 +56,7 @@ func main() {
logger := slog.New(apiHandler)
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfo)
slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
go func() {
err = clipboard.Init()
@@ -60,7 +69,7 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
if err != nil {
panic(err)
}
+100 -12
View File
@@ -23,11 +23,15 @@ import (
type App struct {
Info opencode.App
Modes []opencode.Mode
Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
@@ -35,6 +39,7 @@ type App struct {
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
}
@@ -50,6 +55,9 @@ type SendMsg struct {
Text string
Attachments []opencode.FilePartParam
}
type SetEditorContentMsg struct {
Text string
}
type OptimisticMessageAddedMsg struct {
Message opencode.MessageUnion
}
@@ -61,9 +69,11 @@ func New(
ctx context.Context,
version string,
appInfo opencode.App,
modes []opencode.Mode,
httpClient *opencode.Client,
model *string,
prompt *string,
initialModel *string,
initialPrompt *string,
initialMode *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
@@ -84,14 +94,36 @@ func New(
config.SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
appState.ModeModel = make(map[string]config.ModeModel)
}
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
if configInfo.Model != "" {
splits := strings.Split(configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
var modeIndex int
var mode *opencode.Mode
modeName := "build"
if appState.Mode != "" {
modeName = appState.Mode
}
if initialMode != nil && *initialMode != "" {
modeName = *initialMode
}
for i, m := range modes {
if m.Name == modeName {
modeIndex = i
break
}
}
mode = &modes[modeIndex]
if mode.Model.ModelID != "" {
appState.ModeModel[mode.Name] = config.ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
}
}
if err := theme.LoadThemesFromDirectories(
@@ -116,16 +148,20 @@ func New(
app := &App{
Info: appInfo,
Modes: modes,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
ModeIndex: modeIndex,
Mode: mode,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: model,
InitialPrompt: prompt,
InitialModel: initialModel,
InitialPrompt: initialPrompt,
IntitialMode: initialMode,
}
return app, nil
@@ -159,6 +195,45 @@ func (a *App) SetClipboard(text string) tea.Cmd {
return tea.Sequence(cmds...)
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
a.ModeIndex++
if a.ModeIndex >= len(a.Modes) {
a.ModeIndex = 0
}
a.Mode = &a.Modes[a.ModeIndex]
modelID := a.Mode.Model.ModelID
providerID := a.Mode.Model.ProviderID
if modelID == "" {
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
modelID = model.ModelID
providerID = model.ProviderID
}
}
if modelID != "" {
for _, provider := range a.Providers {
if provider.ID == providerID {
a.Provider = &provider
for _, model := range provider.Models {
if model.ID == modelID {
a.Model = &model
break
}
}
break
}
}
}
a.State.Mode = a.Mode.Name
return a, func() tea.Msg {
a.SaveState()
return nil
}
}
func (a *App) InitializeProvider() tea.Cmd {
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
@@ -195,6 +270,14 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil
}
a.Providers = providers
// retains backwards compatibility with old state format
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
a.State.Provider = model.ProviderID
a.State.Model = model.ModelID
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
@@ -319,10 +402,14 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd {
a.compactCancel = nil
}()
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
_, err := a.Client.Session.Summarize(
compactCtx,
a.Session.ID,
opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
},
)
if err != nil {
if compactCtx.Err() != context.Canceled {
slog.Error("Failed to compact session", "error", err)
@@ -414,6 +501,7 @@ func (a *App) SendChatMessage(
Parts: opencode.F(parts),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
Mode: opencode.F(a.Mode.Name),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
@@ -86,6 +86,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
const (
AppHelpCommand CommandName = "app_help"
SwitchModeCommand CommandName = "switch_mode"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
@@ -152,6 +153,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>h"),
Trigger: []string{"help"},
},
{
Name: SwitchModeCommand,
Description: "switch mode",
Keybindings: parseBindings("tab"),
Trigger: []string{"mode"},
},
{
Name: EditorOpenCommand,
Description: "open editor",
+16 -7
View File
@@ -29,17 +29,26 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
func (c *CommandCompletionProvider) getCommandCompletionItem(
cmd commands.Command,
space int,
t theme.Theme,
) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().
Foreground(t.TextMuted()).
Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
Title: title,
Value: value,
ProviderID: c.GetId(),
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
func (c *CommandCompletionProvider) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
commands := c.app.Commands
@@ -60,7 +69,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
items = append(items, getCommandCompletionItem(cmd, space, t))
items = append(items, c.getCommandCompletionItem(cmd, space, t))
}
return items, nil
}
@@ -77,7 +86,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
// Add all triggers as searchable options
for _, trigger := range cmd.Trigger {
commandNames = append(commandNames, trigger)
commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
}
}
@@ -14,20 +14,20 @@ import (
"github.com/sst/opencode/internal/theme"
)
type filesAndFoldersContextGroup struct {
type filesContextGroup struct {
app *app.App
gitFiles []dialog.CompletionItemI
}
func (cg *filesAndFoldersContextGroup) GetId() string {
func (cg *filesContextGroup) GetId() string {
return "files"
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
func (cg *filesContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
base := styles.NewStyle().Background(t.BackgroundElement())
@@ -50,8 +50,10 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
title += red(" -" + strconv.Itoa(int(file.Removed)))
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: file.Path,
Title: title,
Value: file.Path,
ProviderID: cg.GetId(),
Raw: file,
})
items = append(items, item)
}
@@ -60,7 +62,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
return items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(
func (cg *filesContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
@@ -94,8 +96,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
Title: file,
Value: file,
ProviderID: cg.GetId(),
Raw: file,
})
items = append(items, item)
}
@@ -104,8 +108,8 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
return items, nil
}
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesAndFoldersContextGroup{
func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesContextGroup{
app: app,
}
go func() {
@@ -0,0 +1,118 @@
package completions
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type symbolsContextGroup struct {
app *app.App
}
func (cg *symbolsContextGroup) GetId() string {
return "symbols"
}
func (cg *symbolsContextGroup) GetEmptyMessage() string {
return "no matching symbols"
}
type SymbolKind int
const (
SymbolKindFile SymbolKind = 1
SymbolKindModule SymbolKind = 2
SymbolKindNamespace SymbolKind = 3
SymbolKindPackage SymbolKind = 4
SymbolKindClass SymbolKind = 5
SymbolKindMethod SymbolKind = 6
SymbolKindProperty SymbolKind = 7
SymbolKindField SymbolKind = 8
SymbolKindConstructor SymbolKind = 9
SymbolKindEnum SymbolKind = 10
SymbolKindInterface SymbolKind = 11
SymbolKindFunction SymbolKind = 12
SymbolKindVariable SymbolKind = 13
SymbolKindConstant SymbolKind = 14
SymbolKindString SymbolKind = 15
SymbolKindNumber SymbolKind = 16
SymbolKindBoolean SymbolKind = 17
SymbolKindArray SymbolKind = 18
SymbolKindObject SymbolKind = 19
SymbolKindKey SymbolKind = 20
SymbolKindNull SymbolKind = 21
SymbolKindEnumMember SymbolKind = 22
SymbolKindStruct SymbolKind = 23
SymbolKindEvent SymbolKind = 24
SymbolKindOperator SymbolKind = 25
SymbolKindTypeParameter SymbolKind = 26
)
func (cg *symbolsContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
if query == "" {
return items, nil
}
symbols, err := cg.app.Client.Find.Symbols(
context.Background(),
opencode.FindSymbolsParams{Query: opencode.F(query)},
)
if err != nil {
slog.Error("Failed to get symbol completion items", "error", err)
return items, err
}
if symbols == nil {
return items, nil
}
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
base := baseStyle.Render
muted := baseStyle.Foreground(t.TextMuted()).Render
for _, sym := range *symbols {
parts := strings.Split(sym.Name, ".")
lastPart := parts[len(parts)-1]
title := base(lastPart)
uriParts := strings.Split(sym.Location.Uri, "/")
lastTwoParts := uriParts[len(uriParts)-2:]
joined := strings.Join(lastTwoParts, "/")
title += muted(fmt.Sprintf(" %s", joined))
start := int(sym.Location.Range.Start.Line)
end := int(sym.Location.Range.End.Line)
title += muted(fmt.Sprintf(":L%d-%d", start, end))
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
ProviderID: cg.GetId(),
Raw: sym,
})
items = append(items, item)
}
return items, nil
}
func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
return &symbolsContextGroup{
app: app,
}
}
+47 -18
View File
@@ -38,6 +38,7 @@ type EditorComponent interface {
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetValue(value string)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
}
@@ -96,7 +97,16 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ".pdf":
mediaType = "application/pdf"
default:
mediaType = "text/plain"
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: "text/plain",
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
fileBytes, err := os.ReadFile(filePath)
@@ -127,13 +137,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text := string(msg)
m.textarea.InsertRunesFromUserInput([]rune(text))
case dialog.ThemeSelectedMsg:
m.textarea = m.resetTextareaStyles()
m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
switch msg.ProviderID {
switch msg.Item.GetProviderID() {
case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
commandName := strings.TrimPrefix(msg.Item.GetValue(), "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
@@ -143,7 +153,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.CompletionValue + " ")
m.textarea.InsertString(msg.Item.GetValue() + " ")
return m, nil
}
@@ -154,7 +164,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
filePath := msg.CompletionValue
filePath := msg.Item.GetValue()
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
@@ -177,15 +187,32 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
case "symbols":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.GetValue() + " ")
return m, nil
}
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
symbol := msg.Item.GetRaw().(opencode.Symbol)
parts := strings.Split(symbol.Name, ".")
lastPart := parts[len(parts)-1]
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + lastPart,
URL: msg.Item.GetValue(),
Filename: lastPart,
MediaType: "text/plain",
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID())
return m, nil
}
}
@@ -375,6 +402,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) SetValue(value string) {
m.textarea.SetValue(value)
}
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
m.exitKeyInDebounce = inDebounce
}
@@ -391,14 +422,12 @@ func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0]
}
func (m *editorComponent) resetTextareaStyles() textarea.Model {
func updateTextareaStyles(ta textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := m.textarea
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().
@@ -446,6 +475,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta = updateTextareaStyles(ta)
m := &editorComponent{
app: app,
@@ -453,7 +483,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
spinner: s,
interruptKeyInDebounce: false,
}
m.resetTextareaStyles()
return m
}
@@ -1,6 +1,7 @@
package chat
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
@@ -371,11 +372,65 @@ func (m *messagesComponent) header(width int) string {
headerLines,
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
)
share := ""
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
share = muted(m.app.Session.Share.URL + " /unshare")
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
share = base("/share") + muted(" to create a shareable link")
}
sessionInfo := ""
tokens := float64(0)
cost := float64(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if assistant, ok := message.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
}
// Check if current model is a subscription model (cost is 0 for both input and output)
isSubscriptionModel := m.app.Model != nil &&
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.Background()).
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
background := t.Background()
share = layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
Width: width - 6,
},
layout.FlexItem{
View: share,
},
layout.FlexItem{
View: sessionInfo,
},
)
headerLines = append(headerLines, share)
header := strings.Join(headerLines, "\n")
header = styles.NewStyle().
@@ -393,6 +448,50 @@ func (m *messagesComponent) header(width int) string {
return "\n" + header + "\n"
}
func formatTokensAndCost(
tokens float64,
contextWindow float64,
cost float64,
isSubscriptionModel bool,
) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
percentage := (float64(tokens) / float64(contextWindow)) * 100
if isSubscriptionModel {
return fmt.Sprintf(
"%s/%d%%",
formattedTokens,
int(percentage),
)
}
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"%s/%d%% (%s)",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m *messagesComponent) View(width, height int) string {
t := theme.CurrentTheme()
if m.rendering {
@@ -2,12 +2,15 @@ package dialog
import (
"log/slog"
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -15,32 +18,35 @@ import (
)
type CompletionItem struct {
Title string
Value string
Title string
Value string
ProviderID string
Raw any
}
type CompletionItemI interface {
list.ListItem
GetValue() string
DisplayValue() string
GetProviderID() string
GetRaw() any
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
itemStyle := baseStyle.
Background(t.BackgroundElement()).
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
title := itemStyle.Render(
ci.DisplayValue(),
)
title := itemStyle.Render(truncatedStr)
return title
}
@@ -52,6 +58,14 @@ func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func (ci *CompletionItem) GetProviderID() string {
return ci.ProviderID
}
func (ci *CompletionItem) GetRaw() any {
return ci.Raw
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
@@ -63,9 +77,8 @@ type CompletionProvider interface {
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
ProviderID string
Item CompletionItemI
SearchString string
}
type CompletionDialogCompleteItemMsg struct {
@@ -83,7 +96,7 @@ type CompletionDialog interface {
type completionDialogComponent struct {
query string
completionProvider CompletionProvider
providers []CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
@@ -109,6 +122,52 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
return func() tea.Msg {
allItems := make([]CompletionItemI, 0)
// Collect results from all providers
for _, provider := range c.providers {
items, err := provider.GetChildEntries(query)
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
allItems = append(allItems, items...)
}
// If there's a query, use fuzzy ranking to sort results
if query != "" && len(allItems) > 0 {
// Create a slice of display values for fuzzy matching
displayValues := make([]string, len(allItems))
for i, item := range allItems {
displayValues[i] = item.DisplayValue()
}
// Get fuzzy matches with ranking
matches := fuzzy.RankFindFold(query, displayValues)
// Sort by score (best matches first)
sort.Sort(matches)
// Reorder items based on fuzzy ranking
rankedItems := make([]CompletionItemI, 0, len(matches))
for _, match := range matches {
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
}
return rankedItems
}
return allItems
}
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@@ -126,14 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if query != c.query {
c.query = query
cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.getAllCompletions(query))
}
u, cmd := c.list.Update(msg)
@@ -149,23 +201,18 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return c, c.complete(item)
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left, unless we're back to just the trigger
value := c.pseudoSearchTextArea.Value()
if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
width := lipgloss.Width(value)
triggerWidth := lipgloss.Width(c.trigger)
// Only close on backspace when there are no characters left, unless we're back to just the trigger
if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.getAllCompletions(""))
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...)
}
@@ -177,19 +224,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
maxWidth := 40
completions := c.list.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
width := lipgloss.Width(title)
if width > maxWidth-4 {
maxWidth = width + 4
}
}
c.list.SetMaxWidth(maxWidth)
c.list.SetMaxWidth(c.width)
return baseStyle.
Padding(0, 0).
@@ -213,12 +248,10 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
ProviderID: c.completionProvider.GetId(),
SearchString: value,
Item: item,
}),
c.close(),
)
@@ -230,32 +263,53 @@ func (c *completionDialogComponent) close() tea.Cmd {
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
func NewCompletionDialogComponent(
trigger string,
providers ...CompletionProvider,
) CompletionDialog {
ti := textarea.New()
ti.SetValue(trigger)
// Use a generic empty message if we have multiple providers
emptyMessage := "no matching items"
if len(providers) == 1 {
emptyMessage = providers[0].GetEmptyMessage()
}
li := list.NewListComponent(
[]CompletionItemI{},
7,
completionProvider.GetEmptyMessage(),
emptyMessage,
false,
)
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
// Initialize the textarea with the trigger character
ti.SetValue(trigger)
return &completionDialogComponent{
c := &completionDialogComponent{
query: "",
completionProvider: completionProvider,
providers: providers,
pseudoSearchTextArea: ti,
list: li,
trigger: trigger,
}
// Load initial items from all providers
go func() {
allItems := make([]CompletionItemI, 0)
for _, provider := range providers {
items, err := provider.GetChildEntries("")
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
allItems = append(allItems, items...)
}
li.SetItems(allItems)
}()
return c
}
@@ -1,13 +1,14 @@
package status
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -20,6 +21,7 @@ type StatusComponent interface {
type statusComponent struct {
app *app.App
width int
cwd string
}
func (m statusComponent) Init() tea.Cmd {
@@ -53,38 +55,6 @@ func (m statusComponent) logo() string {
Render(open + code + version)
}
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf(
"Context: %s (%d%%), Cost: %s",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
logo := m.logo()
@@ -93,48 +63,70 @@ func (m statusComponent) View() string {
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Padding(0, 1).
Render(m.app.Info.Path.Cwd)
Render(m.cwd)
sessionInfo := ""
if m.app.Session.ID != "" {
tokens := float64(0)
cost := float64(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if assistant, ok := message.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
}
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement()).
Padding(0, 1).
Render(formatTokensAndCost(tokens, contextWindow, cost))
var modeBackground compat.AdaptiveColor
var modeForeground compat.AdaptiveColor
switch m.app.ModeIndex {
case 0:
modeBackground = t.BackgroundElement()
modeForeground = t.TextMuted()
case 1:
modeBackground = t.Secondary()
modeForeground = t.BackgroundPanel()
case 2:
modeBackground = t.Accent()
modeForeground = t.BackgroundPanel()
case 3:
modeBackground = t.Success()
modeForeground = t.BackgroundPanel()
case 4:
modeBackground = t.Warning()
modeForeground = t.BackgroundPanel()
case 5:
modeBackground = t.Primary()
modeForeground = t.BackgroundPanel()
case 6:
modeBackground = t.Error()
modeForeground = t.BackgroundPanel()
default:
modeBackground = t.Secondary()
modeForeground = t.BackgroundPanel()
}
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
command := m.app.Commands[commands.SwitchModeCommand]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = m.app.Config.Keybinds.Leader + " " + kb.Key
}
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
modeNameStyle := modeStyle.Bold(true).Render
modeDescStyle := modeStyle.Render
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
mode = modeStyle.
Padding(0, 1).
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(modeBackground).
BorderBackground(t.BackgroundPanel()).
Render(mode)
mode = styles.NewStyle().
Faint(true).
Background(t.BackgroundPanel()).
Foreground(t.TextMuted()).
Render(key+" ") +
mode
space := max(
0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
)
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo
status := logo + cwd + spacer + mode
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
@@ -145,5 +137,12 @@ func NewStatusCmp(app *app.App) StatusComponent {
app: app,
}
homePath, err := os.UserHomeDir()
cwdPath := app.Info.Path.Cwd
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
cwdPath = "~" + cwdPath[len(homePath):]
}
statusComponent.cwd = cwdPath
return statusComponent
}
@@ -931,7 +931,6 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
}
// CursorDown moves the cursor down by one line.
// Returns whether or not the cursor blink should be reset.
func (m *Model) CursorDown() {
li := m.LineInfo()
charOffset := max(m.lastCharOffset, li.CharOffset)
@@ -940,11 +939,73 @@ func (m *Model) CursorDown() {
if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
// Move to the next model line
m.row++
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
// We want to land on the first wrapped line of the new model line.
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[0]
// Find position within the first wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundNextLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundNextLine:
m.col = colInLine // startCol is 0 for the first wrapped line
} else if li.RowOffset+1 < li.Height {
// Move to the next wrapped line within the same model line
startOfNextWrappedLine := li.StartColumn + li.Width
m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset)
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[li.RowOffset+1]
startCol := 0
for i := 0; i < li.RowOffset+1; i++ {
startCol += len(grid[i])
}
// Find position within the target wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundSameLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundSameLine:
m.col = startCol + colInLine
}
m.SetCursorColumn(m.col)
}
@@ -956,22 +1017,77 @@ func (m *Model) CursorUp() {
m.lastCharOffset = charOffset
if li.RowOffset <= 0 && m.row > 0 {
// Move to the previous model line
// Move to the previous model line. We want to land on the last wrapped
// line of the previous model line.
m.row--
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
} else if li.RowOffset > 0 {
// Move to the previous wrapped line within the same model line
// To do this, we need to find the start of the previous wrapped line.
prevLineInfo := m.LineInfo()
// prevLineStart := 0
if prevLineInfo.RowOffset > 0 {
// This is complex, so we'll approximate by moving to the start of the current wrapped line
// and then letting characterLeft handle it. A more precise calculation would
// require re-wrapping to find the previous line's start.
// For now, a simpler approach:
m.col = li.StartColumn - 1
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[len(grid)-1]
// Find start of last wrapped line.
startCol := len(m.value[m.row]) - len(targetLineContent)
// Find position within the last wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundPrevLine
}
offset += itemWidth
}
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
colInLine = len(targetLineContent)
foundPrevLine:
m.col = startCol + colInLine
} else if li.RowOffset > 0 {
// Move to the previous wrapped line within the same model line.
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[li.RowOffset-1]
startCol := 0
for i := 0; i < li.RowOffset-1; i++ {
startCol += len(grid[i])
}
// Find position within the target wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundSameLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundSameLine:
m.col = startCol + colInLine
}
m.SetCursorColumn(m.col)
}
@@ -1591,7 +1707,7 @@ func (m Model) View() string {
// guaranteed to be a space since any other character would
// have been wrapped.
wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
padding -= m.width - strwidth
padding = m.width - uniseg.StringWidth(wrappedLineStr)
}
if m.row == l && lineInfo.RowOffset == wl {
@@ -1903,6 +2019,16 @@ func (m *Model) splitLine(row, col int) {
m.row++
}
func itemWidth(item any) int {
switch v := item.(type) {
case rune:
return rw.RuneWidth(v)
case *Attachment:
return uniseg.StringWidth(v.Display)
}
return 0
}
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}
@@ -1945,19 +2071,18 @@ func wrapInterfaces(content []any, width int) [][]any {
}
inSpaces = true
spaceW += itemW
} else {
} else { // It's not a space, it's a character for a word.
if inSpaces {
// End of spaces
if lineW > 0 && lineW+spaceW > width {
lines = append(lines, []any{})
lineW = 0
} else {
lineW += spaceW
}
// Add spaces to current line
// We just finished a block of spaces. Handle them now.
lineW += spaceW
for i := 0; i < spaceW; i++ {
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
}
if lineW > width {
// The spaces made the line overflow. Start a new line for the upcoming word.
lines = append(lines, []any{})
lineW = 0
}
spaceW = 0
}
inSpaces = false
@@ -1966,20 +2091,24 @@ func wrapInterfaces(content []any, width int) [][]any {
}
}
// Handle any remaining word/spaces
// Handle any remaining word/spaces at the end of the content.
if wordW > 0 {
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
if spaceW > 0 {
if lineW > 0 && lineW+spaceW > width {
lines = append(lines, []any{})
}
// There are trailing spaces. Add them.
for i := 0; i < spaceW; i++ {
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
lineW += 1
}
if lineW > width {
lines = append(lines, []any{})
}
}
+15 -6
View File
@@ -16,18 +16,27 @@ type ModelUsage struct {
LastUsed time.Time `toml:"last_used"`
}
type ModeModel struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
}
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
Theme string `toml:"theme"`
ModeModel map[string]ModeModel `toml:"mode_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Mode string `toml:"mode"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
}
}
+25 -9
View File
@@ -23,6 +23,7 @@ import (
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -65,6 +66,7 @@ type appModel struct {
completions dialog.CompletionDialog
commandProvider dialog.CompletionProvider
fileProvider dialog.CompletionProvider
symbolsProvider dialog.CompletionProvider
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
@@ -202,7 +204,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
// Set command provider for command completion
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider, "/")
a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -220,8 +222,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
// Set file provider for file completion
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@")
// Set both file and symbols providers for @ completion
a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -389,6 +391,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCompletionDialog = false
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case app.SetEditorContentMsg:
// Set the editor content without sending
a.editor.SetValue(msg.Text)
updated, cmd := a.editor.Focus()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.fileCompletionActive = false
@@ -517,8 +525,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
ProviderID: msg.Provider.ID,
ModelID: msg.Model.ID,
}
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
@@ -816,6 +826,10 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
case commands.SwitchModeCommand:
updated, cmd := a.app.SwitchMode()
a.app = updated
cmds = append(cmds, cmd)
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
@@ -857,7 +871,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return nil
}
os.Remove(tmpfile.Name())
return app.SendMsg{
return app.SetEditorContentMsg{
Text: string(content),
}
})
@@ -922,7 +936,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
a.modal = themeDialog
case commands.FileListCommand:
a.editor.Blur()
provider := completions.NewFileAndFolderContextGroup(a.app)
provider := completions.NewFileContextGroup(a.app)
findDialog := dialog.NewFindDialog(provider)
findDialog.SetWidth(layout.Current.Container.Width - 8)
a.modal = findDialog
@@ -1030,11 +1044,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
func NewModel(app *app.App) tea.Model {
commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileAndFolderContextGroup(app)
fileProvider := completions.NewFileContextGroup(app)
symbolsProvider := completions.NewSymbolsContextGroup(app)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(commandProvider, "/")
completions := dialog.NewCompletionDialogComponent("/", commandProvider)
var leaderBinding *key.Binding
if app.Config.Keybinds.Leader != "" {
@@ -1050,6 +1065,7 @@ func NewModel(app *app.App) tea.Model {
completions: completions,
commandProvider: commandProvider,
fileProvider: fileProvider,
symbolsProvider: symbolsProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
+4 -4
View File
@@ -1,4 +1,4 @@
configured_endpoints: 21
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-879570c29c56e0a73a0624a84662b7f7c319a3c790c78ec6ac4cf62a7b1a5bd0.yml
openapi_spec_hash: 2432e2dfed22193a0c6b3dfe0f82ec7d
config_hash: 53e3aeb355f3b2e0d10985d6d7635a7e
configured_endpoints: 22
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-37247433660e125f9a79ff173d8007f13192d28a40f4e599e002446a6ed0c128.yml
openapi_spec_hash: 8095ebe2d88259381a58e7b0c87244c4
config_hash: 589ec6a935a43a3c49a325ece86cbda2
+2
View File
@@ -20,12 +20,14 @@ Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#LogLevel">LogLevel</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>
Methods:
- <code title="get /app">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /app/init">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /log">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Log">Log</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppLogParams">AppLogParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /mode">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Modes">Modes</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Find
+56
View File
@@ -55,6 +55,14 @@ func (r *AppService) Log(ctx context.Context, body AppLogParams, opts ...option.
return
}
// List all modes
func (r *AppService) Modes(ctx context.Context, opts ...option.RequestOption) (res *[]Mode, err error) {
opts = append(r.Options[:], opts...)
path := "mode"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
type App struct {
Git bool `json:"git,required"`
Hostname string `json:"hostname,required"`
@@ -149,6 +157,54 @@ func (r LogLevel) IsKnown() bool {
return false
}
type Mode struct {
Name string `json:"name,required"`
Tools map[string]bool `json:"tools,required"`
Model ModeModel `json:"model"`
Prompt string `json:"prompt"`
JSON modeJSON `json:"-"`
}
// modeJSON contains the JSON metadata for the struct [Mode]
type modeJSON struct {
Name apijson.Field
Tools apijson.Field
Model apijson.Field
Prompt apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Mode) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modeJSON) RawJSON() string {
return r.raw
}
type ModeModel struct {
ModelID string `json:"modelID,required"`
ProviderID string `json:"providerID,required"`
JSON modeModelJSON `json:"-"`
}
// modeModelJSON contains the JSON metadata for the struct [ModeModel]
type modeModelJSON struct {
ModelID apijson.Field
ProviderID apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ModeModel) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modeModelJSON) RawJSON() string {
return r.raw
}
type AppLogParams struct {
// Log level
Level param.Field[AppLogParamsLevel] `json:"level,required"`
+22
View File
@@ -85,3 +85,25 @@ func TestAppLogWithOptionalParams(t *testing.T) {
t.Fatalf("err should be nil: %s", err.Error())
}
}
func TestAppModes(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.App.Modes(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
+77 -1
View File
@@ -65,7 +65,8 @@ type Config struct {
// Minimum log level to write to log files
LogLevel LogLevel `json:"log_level"`
// MCP (Model Context Protocol) server configurations
Mcp map[string]ConfigMcp `json:"mcp"`
Mcp map[string]ConfigMcp `json:"mcp"`
Mode ConfigMode `json:"mode"`
// Model to use in the format of provider/model, eg anthropic/claude-2
Model string `json:"model"`
// Custom provider configurations and model overrides
@@ -86,6 +87,7 @@ type configJSON struct {
Keybinds apijson.Field
LogLevel apijson.Field
Mcp apijson.Field
Mode apijson.Field
Model apijson.Field
Provider apijson.Field
Theme apijson.Field
@@ -276,6 +278,77 @@ func (r ConfigMcpType) IsKnown() bool {
return false
}
type ConfigMode struct {
Build ConfigModeBuild `json:"build"`
Plan ConfigModePlan `json:"plan"`
ExtraFields map[string]ConfigMode `json:"-,extras"`
JSON configModeJSON `json:"-"`
}
// configModeJSON contains the JSON metadata for the struct [ConfigMode]
type configModeJSON struct {
Build apijson.Field
Plan apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigMode) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModeJSON) RawJSON() string {
return r.raw
}
type ConfigModeBuild struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Tools map[string]bool `json:"tools"`
JSON configModeBuildJSON `json:"-"`
}
// configModeBuildJSON contains the JSON metadata for the struct [ConfigModeBuild]
type configModeBuildJSON struct {
Model apijson.Field
Prompt apijson.Field
Tools apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigModeBuild) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModeBuildJSON) RawJSON() string {
return r.raw
}
type ConfigModePlan struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Tools map[string]bool `json:"tools"`
JSON configModePlanJSON `json:"-"`
}
// configModePlanJSON contains the JSON metadata for the struct [ConfigModePlan]
type configModePlanJSON struct {
Model apijson.Field
Prompt apijson.Field
Tools apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigModePlan) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModePlanJSON) RawJSON() string {
return r.raw
}
type ConfigProvider struct {
Models map[string]ConfigProviderModel `json:"models,required"`
ID string `json:"id"`
@@ -460,6 +533,8 @@ type Keybinds struct {
SessionShare string `json:"session_share,required"`
// Unshare current session
SessionUnshare string `json:"session_unshare,required"`
// Switch mode
SwitchMode string `json:"switch_mode,required"`
// List available themes
ThemeList string `json:"theme_list,required"`
// Toggle tool details
@@ -500,6 +575,7 @@ type keybindsJSON struct {
SessionNew apijson.Field
SessionShare apijson.Field
SessionUnshare apijson.Field
SwitchMode apijson.Field
ThemeList apijson.Field
ToolDetails apijson.Field
raw string
+31 -22
View File
@@ -439,11 +439,12 @@ type AssistantMessagePart struct {
Type AssistantMessagePartType `json:"type,required"`
ID string `json:"id"`
// This field can have the runtime type of [ToolPartState].
State interface{} `json:"state"`
Text string `json:"text"`
Tool string `json:"tool"`
JSON assistantMessagePartJSON `json:"-"`
union AssistantMessagePartUnion
State interface{} `json:"state"`
Synthetic bool `json:"synthetic"`
Text string `json:"text"`
Tool string `json:"tool"`
JSON assistantMessagePartJSON `json:"-"`
union AssistantMessagePartUnion
}
// assistantMessagePartJSON contains the JSON metadata for the struct
@@ -452,6 +453,7 @@ type assistantMessagePartJSON struct {
Type apijson.Field
ID apijson.Field
State apijson.Field
Synthetic apijson.Field
Text apijson.Field
Tool apijson.Field
raw string
@@ -815,15 +817,17 @@ func (r StepStartPartType) IsKnown() bool {
}
type TextPart struct {
Text string `json:"text,required"`
Type TextPartType `json:"type,required"`
JSON textPartJSON `json:"-"`
Text string `json:"text,required"`
Type TextPartType `json:"type,required"`
Synthetic bool `json:"synthetic"`
JSON textPartJSON `json:"-"`
}
// textPartJSON contains the JSON metadata for the struct [TextPart]
type textPartJSON struct {
Text apijson.Field
Type apijson.Field
Synthetic apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@@ -855,8 +859,9 @@ func (r TextPartType) IsKnown() bool {
}
type TextPartParam struct {
Text param.Field[string] `json:"text,required"`
Type param.Field[TextPartType] `json:"type,required"`
Text param.Field[string] `json:"text,required"`
Type param.Field[TextPartType] `json:"type,required"`
Synthetic param.Field[bool] `json:"synthetic"`
}
func (r TextPartParam) MarshalJSON() (data []byte, err error) {
@@ -1311,13 +1316,14 @@ func (r userMessageTimeJSON) RawJSON() string {
}
type UserMessagePart struct {
Type UserMessagePartType `json:"type,required"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Text string `json:"text"`
URL string `json:"url"`
JSON userMessagePartJSON `json:"-"`
union UserMessagePartUnion
Type UserMessagePartType `json:"type,required"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Synthetic bool `json:"synthetic"`
Text string `json:"text"`
URL string `json:"url"`
JSON userMessagePartJSON `json:"-"`
union UserMessagePartUnion
}
// userMessagePartJSON contains the JSON metadata for the struct [UserMessagePart]
@@ -1325,6 +1331,7 @@ type userMessagePartJSON struct {
Type apijson.Field
Filename apijson.Field
Mime apijson.Field
Synthetic apijson.Field
Text apijson.Field
URL apijson.Field
raw string
@@ -1390,11 +1397,12 @@ func (r UserMessagePartType) IsKnown() bool {
}
type UserMessagePartParam struct {
Type param.Field[UserMessagePartType] `json:"type,required"`
Filename param.Field[string] `json:"filename"`
Mime param.Field[string] `json:"mime"`
Text param.Field[string] `json:"text"`
URL param.Field[string] `json:"url"`
Type param.Field[UserMessagePartType] `json:"type,required"`
Filename param.Field[string] `json:"filename"`
Mime param.Field[string] `json:"mime"`
Synthetic param.Field[bool] `json:"synthetic"`
Text param.Field[string] `json:"text"`
URL param.Field[string] `json:"url"`
}
func (r UserMessagePartParam) MarshalJSON() (data []byte, err error) {
@@ -1409,6 +1417,7 @@ type UserMessagePartUnionParam interface {
}
type SessionChatParams struct {
Mode param.Field[string] `json:"mode,required"`
ModelID param.Field[string] `json:"modelID,required"`
Parts param.Field[[]UserMessagePartUnionParam] `json:"parts,required"`
ProviderID param.Field[string] `json:"providerID,required"`
+4 -2
View File
@@ -117,10 +117,12 @@ func TestSessionChat(t *testing.T) {
context.TODO(),
"id",
opencode.SessionChatParams{
Mode: opencode.F("mode"),
ModelID: opencode.F("modelID"),
Parts: opencode.F([]opencode.UserMessagePartUnionParam{opencode.TextPartParam{
Text: opencode.F("text"),
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F("text"),
Type: opencode.F(opencode.TextPartTypeText),
Synthetic: opencode.F(true),
}}),
ProviderID: opencode.F("providerID"),
},
+19 -1
View File
@@ -7,6 +7,7 @@ import theme from "toolbeam-docs-theme"
import config from "./config.mjs"
import { rehypeHeadingIds } from "@astrojs/markdown-remark"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import { spawnSync } from "child_process"
const github = "https://github.com/sst/opencode"
@@ -26,7 +27,9 @@ export default defineConfig({
markdown: {
rehypePlugins: [rehypeHeadingIds, [rehypeAutolinkHeadings, { behavior: "wrap" }]],
},
build: {},
integrations: [
configSchema(),
solidJs(),
starlight({
title: "opencode",
@@ -60,11 +63,14 @@ export default defineConfig({
"docs",
"docs/cli",
"docs/rules",
"docs/share",
"docs/config",
"docs/models",
"docs/themes",
"docs/keybinds",
"docs/enterprise",
"docs/mcp-servers",
"docs/troubleshooting",
],
components: {
Hero: "./src/components/Hero.astro",
@@ -79,6 +85,18 @@ export default defineConfig({
}),
],
redirects: {
"/discord": "https://discord.gg/invite/opencode",
"/discord": "https://discord.gg/opencode",
},
})
function configSchema() {
return {
name: "configSchema",
hooks: {
"astro:build:done": async () => {
console.log("generating config schema")
spawnSync("../opencode/script/schema.ts", ["./dist/config.json"])
},
},
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
: `https://${stage}.opencode.ai`,
socialCard: "https://social-cards.sst.dev",
github: "https://github.com/sst/opencode",
discord: "https://discord.gg/opencode",
discord: "https://opencode.ai/discord",
headerLinks: [
{ name: "Home", url: "/" },
{ name: "Docs", url: "/docs/" },
+1 -1
View File
@@ -30,7 +30,7 @@
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.4.1"
"toolbeam-docs-theme": "0.4.3"
},
"devDependencies": {
"opencode": "workspace:*",
-32
View File
@@ -1,32 +0,0 @@
import { type JSX, splitProps, createResource } from "solid-js"
import { codeToHtml } from "shiki"
import styles from "./codeblock.module.css"
import { transformerNotationDiff } from "@shikijs/transformers"
interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> {
code: string
lang?: string
}
function CodeBlock(props: CodeBlockProps) {
const [local, rest] = splitProps(props, ["code", "lang"])
const [html] = createResource(
() => [local.code, local.lang],
async ([code, lang]) => {
// TODO: For testing delays
// await new Promise((resolve) => setTimeout(resolve, 3000))
return (await codeToHtml(code || "", {
lang: lang || "text",
themes: {
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})) as string
},
)
return <div innerHTML={html()} class={styles.codeblock} {...rest}></div>
}
export default CodeBlock
+40 -45
View File
@@ -1,13 +1,4 @@
import {
For,
Show,
onMount,
Suspense,
onCleanup,
createMemo,
createSignal,
SuspenseList,
} from "solid-js"
import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js"
import { DateTime } from "luxon"
import { createStore, reconcile } from "solid-js/store"
import { IconArrowDown } from "./icons"
@@ -277,7 +268,6 @@ export default function Share(props: {
}
}
}
console.log(result.messages)
return result
})
@@ -327,42 +317,47 @@ export default function Share(props: {
<div class={styles.parts}>
<SuspenseList revealOrder="forwards">
<For each={data().messages}>
{(msg, msgIndex) => (
<Suspense>
<For
each={msg.parts.filter((x, index) => {
if (x.type === "step-start" && index > 0) return false
if (x.type === "tool" && x.tool === "todoread") return false
if (x.type === "text" && !x.text) return false
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
return false
return true
})}
>
{(part, partIndex) => {
const last = createMemo(
() => data().messages.length === msgIndex() + 1 && msg.parts.length === partIndex() + 1,
)
{(msg, msgIndex) => {
const filteredParts = createMemo(() =>
msg.parts.filter((x, index) => {
if (x.type === "step-start" && index > 0) return false
if (x.type === "tool" && x.tool === "todoread") return false
if (x.type === "text" && !x.text) return false
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
return false
return true
})
)
onMount(() => {
const hash = window.location.hash.slice(1)
// Wait till all parts are loaded
if (
hash !== "" &&
!hasScrolledToAnchor &&
msg.parts.length === partIndex() + 1 &&
data().messages.length === msgIndex() + 1
) {
hasScrolledToAnchor = true
scrollToAnchor(hash)
}
})
return (
<Suspense>
<For each={filteredParts()}>
{(part, partIndex) => {
const last = createMemo(
() =>
data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1,
)
return <Part last={last()} part={part} index={partIndex()} message={msg} />
}}
</For>
</Suspense>
)}
onMount(() => {
const hash = window.location.hash.slice(1)
// Wait till all parts are loaded
if (
hash !== "" &&
!hasScrolledToAnchor &&
filteredParts().length === partIndex() + 1 &&
data().messages.length === msgIndex() + 1
) {
hasScrolledToAnchor = true
scrollToAnchor(hash)
}
})
return <Part last={last()} part={part} index={partIndex()} message={msg} />
}}
</For>
</Suspense>
)
}}
</For>
</SuspenseList>
<div data-section="part" data-part-type="summary">
@@ -1,10 +0,0 @@
.codeblock {
pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important;
span {
white-space: break-spaces;
}
}
}
@@ -127,3 +127,6 @@ The opencode CLI takes the following flags.
| `--help` | `-h` | Display help |
| `--version` | | Print version number |
| `--print-logs` | | Print logs to stderr |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--mode` | | Mode to use |
+112 -11
View File
@@ -24,6 +24,57 @@ When opencode starts up, it looks for a config file in the current directory or
---
## Variable substitution
You can use variable substitution in your config files to reference environment variables and file contents.
### Environment variables
Use `{env:VARIABLE_NAME}` to substitute environment variables:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:OPENCODE_MODEL}",
"provider": {
"anthropic": {
"api_key": "{env:ANTHROPIC_API_KEY}"
}
}
}
```
If the environment variable is not set, it will be replaced with an empty string.
### File contents
Use `{file:path/to/file}` to substitute the contents of a file:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["{file:./custom-instructions.md}"],
"provider": {
"openai": {
"api_key": "{file:~/.secrets/openai-key}"
}
}
}
```
File paths can be:
- Relative to the config file directory
- Absolute paths (starting with `/` or `~`)
This is useful for:
- Keeping sensitive data like API keys in separate files
- Including large instruction files without cluttering your config
- Sharing common configuration snippets across multiple config files
---
## Schema
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
@@ -63,6 +114,36 @@ You can configure the theme you want to use in your opencode config through the
---
### Logging
Logs are written to:
- **macOS/Linux**: `~/.local/share/opencode/log/`
- **Windows**: `%APPDATA%\opencode\log\`
You can configure the minimum log level through the `log_level` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"log_level": "INFO"
}
```
With the following options:
| Level | Description |
| ------- | ---------------------------------------- |
| `DEBUG` | All messages including debug information |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
The **default** log level is `INFO`. If you are running opencode locally in
development mode it's set to `DEBUG`.
---
### Keybinds
You can customize your keybinds through the `keybinds` option.
@@ -93,25 +174,45 @@ You can configure MCP servers you want to use through the `mcp` option.
---
### Logging
### Modes
You can configure the minimum log level through the `log_level` option. This controls which log messages are written to the log files.
You can configure different modes for opencode through the `mode` option. Modes allow you to customize the behavior, tools, and prompts for different use cases.
opencode comes with two built-in modes: `build` (default with all tools enabled) and `plan` (restricted mode with file modification tools disabled). You can override these built-in modes or create your own custom modes.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"log_level": "INFO"
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-20250514",
"prompt": "{file:./prompts/build.txt}",
"tools": {
"write": true,
"edit": true,
"bash": true
}
},
"plan": {
"tools": {
"write": false,
"edit": false,
"bash": false
}
},
"review": {
"prompt": "{file:./prompts/code-review.txt}",
"tools": {
"write": false,
"edit": false,
"bash": false
}
}
}
}
```
Available log levels are:
- `DEBUG` - All messages including debug information
- `INFO` - Informational messages and above (default)
- `WARN` - Warnings and errors only
- `ERROR` - Errors only
The default log level is `INFO` in production and `DEBUG` in development mode.
[Learn more here](/docs/modes).
---
@@ -0,0 +1,63 @@
---
title: Enterprise
description: Using opencode in your organization.
---
opencode does not store any of your code or context data. This makes it easy for
you to use opencode at your organization.
To get started, we recommend:
1. Do a trial internally with your team.
2. [**Contact us**](mailto:hello@sst.dev) to discuss pricing and implementation options.
---
## Trial
Since opencode is open source and does not store any of your code or context data, your developers can simply [get started](/docs/) and carry out a trial.
---
### Data handling
**opencode does not store your code or context data.** All processing happens locally or through direct API calls to your AI provider.
The only caveat here is the optional `/share` feature that must be manually enabled.
---
#### Sharing conversations
If a user enables the `/share` feature, the conversation and the data associated with it are sent to the service we use to host these shares pages at opencode.ai.
The data is currently served through our CDN's edge network, and is cached on the edge near your users.
---
### Code ownership
**You own all code produced by opencode.** There are no licensing restrictions or ownership claims.
---
## Deployment
Once you have completed your trial and you are ready to self-host opencode at
your organization, you can [**contact us**](mailto:hello@sst.dev) to discuss
pricing and implementation options.
---
### SSO
SSO integration can be implemented for enterprise deployments after your trial. Currently users manage and configure individual API keys locally.
This can be switched to a centralized authentication system that your organization uses.
---
### Self-hosting
The share feature can be self-hosted and the share pages can be made accessible
only after the user has been authenticated.
@@ -0,0 +1,173 @@
---
title: Modes
description: Configure different modes for different use cases.
---
Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases. You can switch between modes during a session or configure them in your config file.
## Built-in modes
opencode comes with two built-in modes:
### Build mode (default)
The default mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands.
### Plan mode
A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default:
- `write` - Cannot create new files
- `edit` - Cannot modify existing files
- `patch` - Cannot apply patches
- `bash` - Cannot execute shell commands
This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase.
## Switching modes
You can switch between modes during a session using the `tab` key (or your configured `switch_mode` keybind).
## Configuring modes
You can customize modes in your `opencode.json` config file:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-20250514",
"prompt": "{file:./prompts/build.txt}",
"tools": {
"write": true,
"edit": true,
"bash": true
}
},
"plan": {
"model": "anthropic/claude-haiku-4-20250514",
"tools": {
"write": false,
"edit": false,
"bash": false
}
},
"review": {
"prompt": "{file:./prompts/code-review.txt}",
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
## Mode configuration options
Each mode can be configured with the following options:
### `model`
Override the default model for this mode. Useful for using different models optimized for different tasks (e.g., a faster model for planning, a more capable model for implementation).
```json
{
"mode": {
"plan": {
"model": "anthropic/claude-haiku-4-20250514"
}
}
}
```
### `prompt`
Specify a custom system prompt file for this mode. The prompt file should contain instructions specific to the mode's purpose.
```json
{
"mode": {
"review": {
"prompt": "{file:./prompts/code-review.txt}"
}
}
}
```
### `tools`
Control which tools are available in this mode. You can enable or disable specific tools by setting them to `true` or `false`.
```json
{
"mode": {
"readonly": {
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
## Creating custom modes
You can create your own custom modes by adding them to the `mode` configuration. For example, a documentation mode that focuses on reading and analysis:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"docs": {
"prompt": "{file:./prompts/documentation.txt}",
"tools": {
"write": true,
"edit": true,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
## Available tools
The following tools can be controlled in mode configurations:
| Tool | Description |
| ----------- | ----------------------- |
| `bash` | Execute shell commands |
| `edit` | Modify existing files |
| `write` | Create new files |
| `read` | Read file contents |
| `grep` | Search file contents |
| `glob` | Find files by pattern |
| `list` | List directory contents |
| `patch` | Apply patches to files |
| `todowrite` | Manage todo lists |
| `todoread` | Read todo lists |
| `webfetch` | Fetch web content |
## Use cases
Here are some common use cases for different modes:
- **Build mode**: Full development work with all tools enabled
- **Plan mode**: Analysis and planning without making changes
- **Review mode**: Code review with read-only access plus documentation tools
- **Debug mode**: Focused on investigation with bash and read tools enabled
- **Docs mode**: Documentation writing with file operations but no system commands
@@ -0,0 +1,103 @@
---
title: Share
description: Share your opencode conversations.
---
opencode's share feature allows you to create public links to your opencode conversations, so you can collaborate with teammates or get help from others.
:::note
Shared conversations are publicly accessible to anyone with the link.
:::
---
## How it works
When you share a conversation, opencode:
1. Creates a unique public URL for your session
2. Syncs your conversation history to our servers
3. Makes the conversation accessible via the shareable link — `opencode.ai/s/<share-id>`
---
## Sharing
You can manually share a conversation or enable automatic sharing for all new conversations.
---
### Manual
Use the `/share` command in any conversation to create a shareable link:
```
/share
```
This will generate a unique URL that'll be copied to your clipboard.
---
### Autoshare
You can enable automatic sharing for all new conversations through the `autoshare` option in your [config file](/docs/config).
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"autoshare": true
}
```
By default, `autoshare` is disabled.
---
## Unsharing
To stop sharing a conversation and remove it from public access:
```
/unshare
```
This will remove the share link and delete the data related to the conversation.
---
## Privacy
There are a few things to keep in mind when sharing a conversation.
---
### Data retention
Shared conversations remain accessible until you explicitly unshare them. This
includes:
- Full conversation history
- All messages and responses
- Session metadata
---
### Recommendations
- Only share conversations that don't contain sensitive information
- Review conversation content before sharing
- Unshare conversations when collaboration is complete
- Avoid sharing conversations with proprietary code or confidential data
---
## For enterprises
For enterprise deployments, the share feature can be:
- **Self-hosted** on your own infrastructure
- **Restricted** to authenticated users only
- **Disabled** entirely for security compliance
[Learn more](/docs/enterprise) about using opencode in your organization.
@@ -0,0 +1,87 @@
---
title: Troubleshooting
description: Common issues and how to resolve them.
---
To debug any issues with opencode, you can check the logs or the session data
that it stores locally.
---
### Logs
Log files are written to:
- **macOS/Linux**: `~/.local/share/opencode/log/`
- **Windows**: `%APPDATA%\opencode\log\`
Log files are named with timestamps (e.g., `2025-01-09T123456.log`) and the most recent 10 log files are kept.
You can configure the log level in your [config file](/docs/config#logging) to get more detailed debug information.
---
### Storage
opencode stores session data and other application data on disk at:
- **macOS/Linux**: `~/.local/share/opencode/`
- **Windows**: `%APPDATA%\opencode\`
This directory contains:
- `auth.json` - Authentication data like API keys, OAuth tokens
- `log/` - Application logs
- `project/` - Project-specific data like session and message data
- If the project is within a Git repo, it is stored in `./<project-slug>/storage/`
- If it is not a Git repo, it is stored in `./global/storage/`
---
## Getting help
If you're experiencing issues with opencode:
1. **Report issues on GitHub**
The best way to report bugs or request features is through our GitHub repository:
[**github.com/sst/opencode/issues**](https://github.com/sst/opencode/issues)
Before creating a new issue, search existing issues to see if your problem has already been reported.
2. **Join our Discord**
For real-time help and community discussion, join our Discord server:
[**opencode.ai/discord**](https://opencode.ai/discord)
---
## Common issues
Here are some common issues and how to resolve them.
---
### opencode won't start
1. Check the logs for error messages
2. Try running with `--print-logs` to see output in the terminal
3. Ensure you have the latest version with `opencode upgrade`
---
### Authentication issues
1. Try re-authenticating with `opencode auth login <provider>`
2. Check that your API keys are valid
3. Ensure your network allows connections to the provider's API
---
### Model not available
1. Check that you've authenticated with the provider
2. Verify the model name in your config is correct
3. Some models may require specific access or subscriptions
+2 -2
View File
@@ -9,8 +9,8 @@ SERVER_PID=$!
echo "Waiting for server to start..."
sleep 3
echo "Fetching OpenAPI spec from http://localhost:4096/doc..."
curl -s http://localhost:4096/doc > openapi.json
echo "Fetching OpenAPI spec from http://127.0.0.1:4096/doc..."
curl -s http://127.0.0.1:4096/doc > openapi.json
echo "Stopping server..."
kill $SERVER_PID
+2
View File
@@ -49,10 +49,12 @@ resources:
models:
app: App
logLevel: LogLevel
mode: Mode
methods:
get: get /app
init: post /app/init
log: post /log
modes: get /mode
find:
models: