Compare commits

...

65 Commits

Author SHA1 Message Date
Dax Raad 085c0e4e2b respect go.work when spawning LSP
publish / publish (push) Has been cancelled
2025-07-09 22:54:47 -04:00
Dax Raad 8404a97c3e better detection of prettier formatter
publish / publish (push) Has been cancelled
2025-07-09 22:37:31 -04:00
Dax Raad 0ee3b1ede2 do not wait for LSP to be fully ready
publish / publish (push) Has been cancelled
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)
publish / publish (push) Has been cancelled
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
Dax Raad f383008cc1 lsp: spawn only a single tsserver in project root
publish / publish (push) Has been cancelled
2025-07-09 11:06:44 -04:00
adamdottv 303ade25ed feat: discord redirect 2025-07-09 10:01:42 -05:00
adamdottv 53f8e7850e feat: configurable log levels 2025-07-09 10:00:03 -05:00
adamdottv ca8ce88354 feat(tui): move logging to server logs 2025-07-09 08:16:10 -05:00
adamdottv 37a86439c4 fix(tui): don't panic on missing linux clipboard tool 2025-07-09 06:51:58 -05:00
adamdottv 269b43f4de fix(tui): markdown wrapping off sometimes 2025-07-09 06:41:53 -05:00
adamdottv 3f25e5bf86 chore: internal clipboard package 2025-07-09 04:55:24 -05:00
Aiden Cline 67765fa47c tweak: keep completion options open when trigger is still present (#789) 2025-07-09 04:42:31 -05:00
adamdottv 58b1c58bc5 fix(tui): clear command priority
publish / publish (push) Has been cancelled
2025-07-08 19:26:50 -05:00
Dax Raad d80badc50f ci: ignore chore commits 2025-07-08 20:05:33 -04:00
Dax Raad 75279e5ccf wip: symbols endpoint 2025-07-08 20:05:33 -04:00
Yihui Khuu 7893b84614 Add debounce before exit when using non-leader exit command (#759) 2025-07-08 18:53:38 -05:00
Dax Raad cfc715bd48 wip: remove excess import
publish / publish (push) Has been cancelled
2025-07-08 19:51:09 -04:00
adamdottv 39bcba85a9 chore: vendor clipboard into go package 2025-07-08 18:48:40 -05:00
adamdottv da3df51316 chore: remove clipboard temp 2025-07-08 18:47:59 -05:00
adamdottv 12190e4efc chore: vendor clipboard into go package 2025-07-08 18:46:42 -05:00
Aiden Cline d2a9b2f64a fix: documentation typo (#781) 2025-07-08 18:30:46 -05:00
adamdottv aacadd8a8a fix(tui): panic when reading/writing clipboard on linux 2025-07-08 18:29:45 -05:00
Jay V 969154a473 docs: share page image 2025-07-08 19:24:21 -04:00
Jay V 4d6ca3fab1 docs: share page many model case 2025-07-08 19:08:33 -04:00
Dax Raad 00ea5082e7 add typescript lsp timeout if it fails to start
publish / publish (push) Has been cancelled
2025-07-08 18:33:12 -04:00
Dax Raad 4a878b88c0 properly load typescript lsp in subpaths
publish / publish (push) Has been cancelled
2025-07-08 18:18:45 -04:00
Dax Raad 6de955847c big rework of LSP system 2025-07-08 18:14:49 -04:00
Jay V 3ba5d528b4 docs: share bugs 2025-07-08 18:14:36 -04:00
Jay V f99e2b3429 docs: share error part 2025-07-08 18:00:08 -04:00
Jay V 7e4e6f6e51 docs: share page bugs 2025-07-08 17:18:38 -04:00
Jay V 0514f3f43b docs: share image model 2025-07-08 17:18:38 -04:00
Timo Clasen 1e07384364 fix: make compact command interruptible (#691)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
2025-07-08 15:37:25 -05:00
strager 4c4739c422 fix(tool): fix ripgrep invocation on Windows (#700)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-07-08 15:36:26 -05:00
Rami Chowdhury 2d8b90a6ff feat(storage): ensure storage directory exists and handle paths correctly (#771) 2025-07-08 15:34:11 -05:00
Robb Currall a2fa7ffa42 fix: support cancelled task state (#775) 2025-07-08 15:33:39 -05:00
Frank Denis f7d6175283 Add support for the Zig Language Server (ZLS) (#756) 2025-07-08 15:31:11 -05:00
Tommy 9ed187ee52 docs: add terminal requirements (#708) 2025-07-08 15:30:05 -05:00
Gal Schlezinger 14d81e574b [config json schema] declare default values and examples for in-ide documentation (#754) 2025-07-08 15:29:07 -05:00
adamdottv 6efe8cc8df fix: env has to be string
publish / publish (push) Has been cancelled
2025-07-08 14:59:03 -05:00
adamdottv daa5fc916a fix(tui): pasting causes panic on macos 2025-07-08 14:57:17 -05:00
adamdottv c659496b96 fix(tui): model/provider arg parsing 2025-07-08 14:11:57 -05:00
Timo Clasen 21fbf21cb6 fix(copilot): add vision request header (#773) 2025-07-08 14:01:54 -05:00
adamdottv f31cbf2744 fix: image reading
publish / publish (push) Has been cancelled
2025-07-08 13:02:13 -05:00
Aiden Cline 8322f18e03 fix: display errors when using opencode run ... (#751) 2025-07-08 10:38:11 -05:00
adamdottv 562bdb95e2 fix: include symlinks in ripgrep searches 2025-07-08 10:02:19 -05:00
Dax a57ce8365d Update STATS.md 2025-07-08 10:30:02 -04:00
adamdottv 0da83ae67e feat(tui): command aliases 2025-07-08 08:20:55 -05:00
adamdottv 662d022a48 feat(tui): paste images and pdfs 2025-07-08 08:09:01 -05:00
GitHub Action 9efef03919 ignore: update download stats 2025-07-08 2025-07-08 12:04:27 +00:00
GitHub Action 7a9fb3fa92 ignore: update download stats 2025-07-08 2025-07-08 10:51:06 +00:00
adamdottv ea96ead346 feat(tui): handle --model and --prompt flags 2025-07-08 05:50:18 -05:00
Dax Raad 6100a77b85 start file watcher only for tui 2025-07-07 21:05:04 -04:00
Dax Raad c7a59ee2b1 better handling of aborting sessions
publish / publish (push) Has been cancelled
2025-07-07 20:59:00 -04:00
Jay V a272b58fe9 docs: intro 2025-07-07 17:41:46 -04:00
100 changed files with 4049 additions and 1675 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>
+11 -10
View File
@@ -1,12 +1,13 @@
# 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) |
| 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) |
+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=="],
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
"stainless": "./scripts/stainless",
"postinstall": "./scripts/hooks"
},
"workspaces": {
-369
View File
@@ -1,369 +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",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string",
"description": "Exit the application"
}
},
"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"
},
"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": {
"name": {
"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
},
"id": {
"type": "string"
},
"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#"
}
+1
View File
@@ -103,6 +103,7 @@ if (!snapshot) {
const lower = x.toLowerCase()
return (
!lower.includes("ignore:") &&
!lower.includes("chore:") &&
!lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") &&
+29 -2
View File
@@ -4,5 +4,32 @@ import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
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`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
postProcess(jsonSchema) {
const schema = jsonSchema as typeof jsonSchema & {
examples?: unknown[]
}
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
return jsonSchema
},
})
await Bun.write(file, JSON.stringify(result, null, 2))
-2
View File
@@ -1,6 +1,5 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { FileWatcher } from "../file/watch"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
@@ -11,7 +10,6 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
Format.init()
ConfigHooks.init()
LSP.init()
FileWatcher.init()
return cb(app)
})
@@ -25,7 +25,6 @@ export const SymbolsCommand = cmd({
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
@@ -45,7 +45,7 @@ const FilesCommand = cmd({
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob,
glob: args.glob ? [args.glob] : undefined,
limit: args.limit,
})
console.log(files.join("\n"))
+15
View File
@@ -125,6 +125,20 @@ export const RunCommand = cmd({
}
})
let errorMsg: string | undefined
Bus.subscribe(Session.Event.Error, async (evt) => {
const { sessionID, error } = evt.properties
if (sessionID !== session.id || !error) return
let err = String(error.name)
if ("data" in error && error.data && "message" in error.data) {
err = error.data.message
}
errorMsg = errorMsg ? errorMsg + "\n" + err : err
UI.error(err)
})
const result = await Session.chat({
sessionID: session.id,
providerID,
@@ -140,6 +154,7 @@ export const RunCommand = cmd({
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
if (errorMsg) process.stdout.write(errorMsg)
}
UI.empty()
})
+3
View File
@@ -10,6 +10,7 @@ import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -40,6 +41,7 @@ export const TuiCommand = cmd({
return
}
const result = await bootstrap({ cwd }, async (app) => {
FileWatcher.init()
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
@@ -82,6 +84,7 @@ export const TuiCommand = cmd({
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
+57 -27
View File
@@ -55,40 +55,62 @@ 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().describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z.string().optional().describe("Interrupt current session"),
session_compact: z.string().optional().describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z.string().optional().describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z.string().optional().describe("Navigate to previous history item"),
history_next: z.string().optional().describe("Navigate to next history item"),
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
messages_previous: z.string().optional().describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z.string().optional().describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
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"),
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"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
file_list: z.string().optional().default("<leader>f").describe("List files"),
file_close: z.string().optional().default("esc").describe("Close file"),
file_search: z.string().optional().default("<leader>/").describe("Search file"),
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.default("ctrl+alt+d")
.describe("Scroll messages down by half page"),
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
})
.strict()
.openapi({
ref: "KeybindsConfig",
})
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
@@ -98,6 +120,14 @@ 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(
ModelsDev.Provider.partial().extend({
+18 -5
View File
@@ -11,6 +11,19 @@ import { Log } from "../util/log"
export namespace File {
const log = Log.create({ service: "file" })
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.openapi({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Event = {
Edited: Bus.event(
"file.edited",
@@ -26,14 +39,14 @@ export namespace File {
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const changedFiles = []
const changedFiles: Info[] = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
file: filepath,
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
@@ -50,7 +63,7 @@ export namespace File {
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
path: filepath,
added: lines,
removed: 0,
status: "added",
@@ -68,7 +81,7 @@ export namespace File {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
file: filepath,
path: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
@@ -78,7 +91,7 @@ export namespace File {
return changedFiles.map((x) => ({
...x,
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
path: path.relative(app.path.cwd, path.join(app.path.root, x.path)),
}))
}
+29 -22
View File
@@ -34,25 +34,27 @@ export namespace Ripgrep {
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
data: z
.object({
path: z.object({
text: z.string(),
}),
),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
})
.openapi({ ref: "Match" }),
})
const End = z.object({
@@ -185,10 +187,15 @@ export namespace Ripgrep {
return filepath
}
export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
if (input.glob) {
for (const g of input.glob) {
commands[0] += ` --glob='${g}'`
}
}
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
+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
},
}
+18
View File
@@ -40,6 +40,24 @@ const cli = yargs(hideBin(process.argv))
})
.middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") })
try {
const { Config } = await import("./config/config")
const { App } = await import("./app/app")
App.provide({ cwd: process.cwd() }, async () => {
const cfg = await Config.get()
if (cfg.log_level) {
Log.setLevel(cfg.log_level as Log.Level)
} else {
const defaultLevel = Installation.isDev() ? "DEBUG" : "INFO"
Log.setLevel(defaultLevel)
}
})
} catch (e) {
Log.Default.error("failed to load config", { error: e })
}
Log.Default.info("opencode", {
version: Installation.VERSION,
args: process.argv.slice(2),
+30 -20
View File
@@ -34,46 +34,54 @@ export namespace LSPClient {
),
}
export async function create(serverID: string, server: LSPServer.Handle) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const app = App.info()
log.info("starting client", { id: serverID })
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(server.process.stdout),
new StreamMessageWriter(server.process.stdin),
new StreamMessageReader(input.server.process.stdout),
new StreamMessageWriter(input.server.process.stdin),
)
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname
log.info("textDocument/publishDiagnostics", {
l.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
if (!exists && input.serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
})
connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
return [{}]
})
connection.listen()
log.info("sending initialize", { id: serverID })
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
rootUri: "file://" + input.root,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
uri: "file://" + input.root,
},
],
initializationOptions: {
...server.initialization,
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
},
@@ -90,9 +98,9 @@ export namespace LSPClient {
}),
5_000,
).catch((err) => {
log.error("initialize error", { error: err })
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{ serverID: input.serverID },
{
cause: err,
},
@@ -100,17 +108,15 @@ export namespace LSPClient {
})
await connection.sendNotification("initialized", {})
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
} = {}
const result = {
root: input.root,
get serverID() {
return serverID
return input.serverID
},
get connection() {
return connection
@@ -170,13 +176,17 @@ export namespace LSPClient {
})
},
async shutdown() {
log.info("shutting down", { serverID })
l.info("shutting down")
connection.end()
connection.dispose()
log.info("shutdown", { serverID })
input.server.process.kill()
l.info("shutdown")
},
}
if (input.server.onInitialized) input.server.onInitialized(result)
l.info("initialized")
return result
}
}
+30 -17
View File
@@ -3,8 +3,8 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -28,7 +28,7 @@ export namespace LSP {
}),
})
.openapi({
ref: "LSP.Symbol",
ref: "Symbol",
})
export type Symbol = z.infer<typeof Symbol>
@@ -36,29 +36,39 @@ export namespace LSP {
"lsp",
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const clients: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
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,
})
if (!file) continue
const handle = await server.spawn(App.info())
const handle = await server.spawn(App.info(), root)
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
handle.process.kill()
log.error("", { error: err })
})
if (!client) break
clients.set(server.id, client)
break
clients.push(client)
}
}
log.info("initialized")
return {
clients,
}
},
async (state) => {
for (const client of state.clients.values()) {
for (const client of state.clients) {
await client.shutdown()
}
},
@@ -109,14 +119,17 @@ export namespace LSP {
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection.sendRequest("workspace/symbol", {
query,
}),
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}
+2
View File
@@ -94,4 +94,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".zig": "zig",
".zon": "zig",
} as const
+175 -9
View File
@@ -6,6 +6,10 @@ 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"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -13,21 +17,39 @@ 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[]>
const SimpleRoots = (patterns: string[]): RootsFunction => {
return async (app) => {
const files = await Ripgrep.files({
glob: patterns.map((p) => `**/${p}`),
cwd: app.path.root,
})
const dirs = files.map((file) => path.dirname(file))
return unique(dirs).map((dir) => path.join(app.path.root, dir))
}
}
export interface Info {
id: string
extensions: string[]
spawn(app: App.Info): Promise<Handle | undefined>
global?: boolean
roots: (app: App.Info) => Promise<string[]>
spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
roots: async (app) => [app.path.root],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -40,14 +62,36 @@ 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: async (app) => {
const work = await SimpleRoots(["go.work"])(app)
if (work.length > 0) return work
return SimpleRoots(["go.mod", "go.sum"])(app)
},
extensions: [".go"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -72,15 +116,18 @@ export namespace LSPServer {
})
}
return {
process: spawn(bin!),
process: spawn(bin!, {
cwd: root,
}),
}
},
}
export const RubyLsp: Info = {
id: "ruby-lsp",
roots: SimpleRoots(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -109,7 +156,9 @@ export namespace LSPServer {
})
}
return {
process: spawn(bin!, ["--stdio"]),
process: spawn(bin!, ["--stdio"], {
cwd: root,
}),
}
},
}
@@ -117,8 +166,17 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
roots: SimpleRoots([
"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,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -133,7 +191,8 @@ export namespace LSPServer {
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
roots: SimpleRoots(["mix.exs", "mix.lock"]),
async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -177,7 +236,114 @@ export namespace LSPServer {
}
return {
process: spawn(binary),
process: spawn(binary, {
cwd: root,
}),
}
},
}
export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
roots: SimpleRoots(["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,
}),
}
},
}
+9 -1
View File
@@ -99,19 +99,27 @@ export namespace Provider {
info.access = tokens.access
}
let isAgentCall = false
let isVisionRequest = false
try {
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
if (body?.messages) {
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
isVisionRequest = body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
)
}
} catch {}
const headers = {
const headers: Record<string, string> = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
}
if (isVisionRequest) {
headers["Copilot-Vision-Request"] = "true"
}
delete headers["x-api-key"]
return fetch(input, {
...init,
+72 -11
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: {
@@ -558,7 +559,7 @@ export namespace Server {
description: "Symbols",
content: {
"application/json": {
schema: resolver(z.unknown().array()),
schema: resolver(LSP.Symbol.array()),
},
},
},
@@ -621,16 +622,7 @@ export namespace Server {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
schema: resolver(File.Info.array()),
},
},
},
@@ -641,6 +633,75 @@ export namespace Server {
return c.json(content)
},
)
.post(
"/log",
describeRoute({
description: "Write a log entry to the server logs",
responses: {
200: {
description: "Log entry written successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
service: z.string().openapi({ description: "Service name for the log entry" }),
level: z.enum(["debug", "info", "error", "warn"]).openapi({ description: "Log level" }),
message: z.string().openapi({ description: "Log message" }),
extra: z
.record(z.string(), z.any())
.optional()
.openapi({ description: "Additional metadata for the log entry" }),
}),
),
async (c) => {
const { service, level, message, extra } = c.req.valid("json")
const logger = Log.create({ service })
switch (level) {
case "debug":
logger.debug(message, extra)
break
case "info":
logger.info(message, extra)
break
case "error":
logger.error(message, extra)
break
case "warn":
logger.warn(message, extra)
break
}
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
}
+250 -164
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,12 +30,12 @@ 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"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -99,6 +100,7 @@ export namespace Session {
Error: Bus.event(
"session.error",
z.object({
sessionID: z.string().optional(),
error: MessageV2.Assistant.shape.error,
}),
),
@@ -280,13 +282,13 @@ 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)
@@ -363,6 +365,7 @@ export namespace Session {
return [
{
type: "text",
synthetic: true,
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
},
]
@@ -371,11 +374,12 @@ export namespace Session {
return [
{
type: "text",
text: ["Called the Read tool on " + url.pathname].join("\n"),
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
synthetic: true,
},
{
type: "file",
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64url"),
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
mime: part.mime,
filename: part.filename!,
},
@@ -385,6 +389,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,
@@ -430,9 +442,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"),
@@ -461,7 +477,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,
@@ -493,6 +510,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) => {
@@ -552,167 +570,197 @@ export namespace Session {
],
}),
})
for await (const value of result.fullStream) {
l.info("part", {
type: value.type,
})
switch (value.type) {
case "start":
break
try {
for await (const value of result.fullStream) {
l.info("part", {
type: value.type,
})
switch (value.type) {
case "start":
break
case "tool-input-start":
next.parts.push({
type: "tool",
tool: value.toolName,
id: value.id,
state: {
status: "pending",
},
})
Bus.publish(MessageV2.Event.PartUpdated, {
part: next.parts[next.parts.length - 1],
sessionID: next.sessionID,
messageID: next.id,
})
break
case "tool-input-delta":
break
case "tool-call": {
const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId)
if (match) {
match.state = {
status: "running",
input: value.input,
time: {
start: Date.now(),
case "tool-input-start":
next.parts.push({
type: "tool",
tool: value.toolName,
id: value.id,
state: {
status: "pending",
},
}
})
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
part: next.parts[next.parts.length - 1],
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "tool-result": {
const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
break
case "tool-error": {
const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId)
if (match && match.state.status === "running") {
match.state = {
status: "error",
input: value.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "tool-input-delta":
break
case "error":
const e = value.error
log.error("", {
error: e,
})
switch (true) {
case MessageV2.OutputLengthError.isInstance(e):
next.error = e
break
case LoadAPIKeyError.isInstance(e):
next.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
case "tool-call": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match) {
match.state = {
status: "running",
input: value.input,
time: {
start: Date.now(),
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
Bus.publish(Event.Error, {
error: next.error,
})
break
case "start-step":
next.parts.push({
type: "step-start",
})
break
case "finish-step":
const usage = getUsage(model.info, value.usage, value.providerMetadata)
next.cost += usage.cost
next.tokens = usage.tokens
break
case "text-start":
text = {
type: "text",
text: "",
case "tool-result": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
break
case "text":
if (text.text === "") next.parts.push(text)
text.text += value.text
break
case "tool-error": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match && match.state.status === "running") {
match.state = {
status: "error",
input: value.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "text-end":
Bus.publish(MessageV2.Event.PartUpdated, {
part: text,
sessionID: next.sessionID,
messageID: next.id,
})
break
case "error":
throw value.error
case "finish":
next.time.completed = Date.now()
break
case "start-step":
next.parts.push({
type: "step-start",
})
break
default:
l.info("unhandled", {
...value,
})
continue
case "finish-step":
const usage = getUsage(model.info, value.usage, value.providerMetadata)
next.cost += usage.cost
next.tokens = usage.tokens
break
case "text-start":
text = {
type: "text",
text: "",
}
break
case "text":
if (text.text === "") next.parts.push(text)
text.text += value.text
break
case "text-end":
Bus.publish(MessageV2.Event.PartUpdated, {
part: text,
sessionID: next.sessionID,
messageID: next.id,
})
break
case "finish":
next.time.completed = Date.now()
break
default:
l.info("unhandled", {
...value,
})
continue
}
await updateMessage(next)
}
} catch (e) {
log.error("", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
next.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
next.error = e
break
case LoadAPIKeyError.isInstance(e):
next.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
}
Bus.publish(Event.Error, {
sessionID: next.sessionID,
error: next.error,
})
}
for (const part of next.parts) {
if (part.type === "tool" && part.state.status !== "completed") {
part.state = {
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
input: {},
}
}
await updateMessage(next)
}
next.time.completed = Date.now()
await updateMessage(next)
@@ -842,20 +890,58 @@ export namespace Session {
},
})
for await (const value of result.fullStream) {
switch (value.type) {
case "text":
if (!text) {
text = {
type: "text",
text: value.text,
}
next.parts.push(text)
} else text.text += value.text
await updateMessage(next)
break
try {
for await (const value of result.fullStream) {
switch (value.type) {
case "text":
if (!text) {
text = {
type: "text",
text: value.text,
}
next.parts.push(text)
} else text.text += value.text
await updateMessage(next)
break
}
}
} catch (e: any) {
log.error("summarize stream error", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
next.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
next.error = e
break
case LoadAPIKeyError.isInstance(e):
next.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject()
}
Bus.publish(Event.Error, {
error: next.error,
})
}
next.time.completed = Date.now()
await updateMessage(next)
}
function lock(sessionID: string) {
+8 -1
View File
@@ -7,6 +7,7 @@ import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
export const ToolStatePending = z
.object({
@@ -75,6 +76,7 @@ export namespace MessageV2 {
.object({
type: z.literal("text"),
text: z.string(),
synthetic: z.boolean().optional(),
})
.openapi({
ref: "TextPart",
@@ -148,7 +150,12 @@ export namespace MessageV2 {
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [Provider.AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
])
.optional(),
system: z.string().array(),
modelID: z.string(),
+69
View File
@@ -0,0 +1,69 @@
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,
},
},
},
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 -1
View File
@@ -39,7 +39,8 @@ export namespace Storage {
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.join(app.path.data, "storage")
const dir = path.normalize(path.join(app.path.data, "storage"))
await fs.mkdir(dir, { recursive: true })
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
+1 -1
View File
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
let truncated = false
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
glob: [params.pattern],
})) {
if (files.length >= limit) {
truncated = true
+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 {
+1 -1
View File
@@ -2,7 +2,7 @@ Reads a file from the local filesystem. You can access any file directly by usin
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- The filePath parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
+1 -1
View File
@@ -5,7 +5,7 @@ import { App } from "../app/app"
const TodoInfo = z.object({
content: z.string().min(1).describe("Brief description of the task"),
status: z.enum(["pending", "in_progress", "completed"]).describe("Current status of the task"),
status: z.enum(["pending", "in_progress", "completed", "cancelled"]).describe("Current status of the task"),
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
id: z.string().describe("Unique identifier for the todo item"),
})
+11 -1
View File
@@ -1,7 +1,17 @@
import { exists } from "fs/promises"
import { dirname, join } from "path"
import { dirname, join, relative } from "path"
export namespace Filesystem {
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return relative(parent, child).startsWith("..")
}
export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []
+72 -5
View File
@@ -1,15 +1,59 @@
import path from "path"
import fs from "fs/promises"
import { Global } from "../global"
import z from "zod"
export namespace Log {
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).openapi({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
let currentLevel: Level = "INFO"
export function setLevel(level: Level) {
currentLevel = level
}
export function getLevel(): Level {
return currentLevel
}
function shouldLog(level: Level): boolean {
return levelPriority[level] >= levelPriority[currentLevel]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
@@ -47,6 +91,14 @@ export namespace Log {
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
@@ -60,15 +112,26 @@ export namespace Log {
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result = {
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
process.stderr.write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
process.stderr.write("INFO " + build(message, extra))
if (shouldLog("INFO")) {
process.stderr.write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
process.stderr.write("ERROR " + build(message, extra))
if (shouldLog("ERROR")) {
process.stderr.write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
process.stderr.write("WARN " + build(message, extra))
if (shouldLog("WARN")) {
process.stderr.write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
@@ -96,6 +159,10 @@ export namespace Log {
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}
}
+20 -25
View File
@@ -5,14 +5,16 @@ import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
flag "github.com/spf13/pflag"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/util"
)
var Version = "dev"
@@ -23,6 +25,10 @@ func main() {
version = "v" + Version
}
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
@@ -33,39 +39,28 @@ func main() {
os.Exit(1)
}
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
slog.Error("Failed to create log directory", "error", err)
os.Exit(1)
}
}
file, err := os.Create(logfile)
if err != nil {
slog.Error("Failed to create log file", "error", err)
os.Exit(1)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfo)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
}
apiHandler := util.NewAPILogHandler(httpClient, "tui", slog.LevelDebug)
logger := slog.New(apiHandler)
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfo)
go func() {
err = clipboard.Init()
if err != nil {
slog.Error("Failed to initialize clipboard", "error", err)
}
}()
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app_, err := app.New(ctx, version, appInfo, httpClient)
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
if err != nil {
panic(err)
}
+11 -12
View File
@@ -17,7 +17,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
github.com/tidwall/gjson v1.14.4
golang.org/x/image v0.28.0
rsc.io/qr v0.2.0
)
@@ -50,23 +50,23 @@ require (
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
@@ -78,16 +78,15 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.6
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/image v0.26.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+16 -19
View File
@@ -54,8 +54,6 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
@@ -218,14 +216,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -236,15 +233,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -263,28 +260,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+133 -70
View File
@@ -12,6 +12,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
@@ -21,17 +22,20 @@ import (
)
type App struct {
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
compactCancel context.CancelFunc
}
type SessionSelectedMsg = *opencode.Session
@@ -58,6 +62,8 @@ func New(
version string,
appInfo opencode.App,
httpClient *opencode.Client,
model *string,
prompt *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
@@ -109,15 +115,17 @@ func New(
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: model,
InitialPrompt: prompt,
}
return app, nil
@@ -140,66 +148,102 @@ func (a *App) Key(commandName commands.CommandName) string {
return base(key) + muted(" "+command.Description)
}
func (a *App) SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
return nil
})
// try to set the clipboard using OSC52 for terminals that support it
cmds = append(cmds, tea.SetClipboard(text))
return tea.Sequence(cmds...)
}
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
var initialProvider *opencode.Provider
var initialModel *opencode.Model
if a.InitialModel != nil && *a.InitialModel != "" {
splits := strings.Split(*a.InitialModel, "/")
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
if provider.ID == splits[0] {
initialProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
modelID := strings.Join(splits[1:], "/")
if model.ID == modelID {
initialModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
}
if initialProvider != nil && initialModel != nil {
currentProvider = initialProvider
currentModel = initialModel
}
var cmds []tea.Cmd
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
func getDefaultModel(
@@ -263,13 +307,26 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
if a.compactCancel != nil {
a.compactCancel()
}
compactCtx, cancel := context.WithCancel(ctx)
a.compactCancel = cancel
go func() {
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
defer func() {
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),
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
if compactCtx.Err() != context.Canceled {
slog.Error("Failed to compact session", "error", err)
}
}
}()
return nil
@@ -372,6 +429,12 @@ func (a *App) SendChatMessage(
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
// Cancel any running compact operation
if a.compactCancel != nil {
a.compactCancel()
a.compactCancel = nil
}
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
@@ -0,0 +1,155 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
/*
Package clipboard provides cross platform clipboard access and supports
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
clipboard, one must call Init to assert if it is possible to use this
package:
err := clipboard.Init()
if err != nil {
panic(err)
}
The most common operations are `Read` and `Write`. To use them:
// write/read text format data of the clipboard, and
// the byte buffer regarding the text are UTF8 encoded.
clipboard.Write(clipboard.FmtText, []byte("text data"))
clipboard.Read(clipboard.FmtText)
// write/read image format data of the clipboard, and
// the byte buffer regarding the image are PNG encoded.
clipboard.Write(clipboard.FmtImage, []byte("image data"))
clipboard.Read(clipboard.FmtImage)
Note that read/write regarding image format assumes that the bytes are
PNG encoded since it serves the alpha blending purpose that might be
used in other graphical software.
In addition, `clipboard.Write` returns a channel that can receive an
empty struct as a signal, which indicates the corresponding write call
to the clipboard is outdated, meaning the clipboard has been overwritten
by others and the previously written data is lost. For instance:
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
select {
case <-changed:
println(`"text data" is no longer available from clipboard.`)
}
You can ignore the returning channel if you don't need this type of
notification. Furthermore, when you need more than just knowing whether
clipboard data is changed, use the watcher API:
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
for data := range ch {
// print out clipboard data whenever it is changed
println(string(data))
}
*/
package clipboard
import (
"context"
"errors"
"fmt"
"os"
"sync"
)
var (
// activate only for running tests.
debug = false
errUnavailable = errors.New("clipboard unavailable")
errUnsupported = errors.New("unsupported format")
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
)
// Format represents the format of clipboard data.
type Format int
// All sorts of supported clipboard data
const (
// FmtText indicates plain text clipboard format
FmtText Format = iota
// FmtImage indicates image/png clipboard format
FmtImage
)
var (
// Due to the limitation on operating systems (such as darwin),
// concurrent read can even cause panic, use a global lock to
// guarantee one read at a time.
lock = sync.Mutex{}
initOnce sync.Once
initError error
)
// Init initializes the clipboard package. It returns an error
// if the clipboard is not available to use. This may happen if the
// target system lacks required dependency, such as libx11-dev in X11
// environment. For example,
//
// err := clipboard.Init()
// if err != nil {
// panic(err)
// }
//
// If Init returns an error, any subsequent Read/Write/Watch call
// may result in an unrecoverable panic.
func Init() error {
initOnce.Do(func() {
initError = initialize()
})
return initError
}
// Read returns a chunk of bytes of the clipboard data if it presents
// in the desired format t presents. Otherwise, it returns nil.
func Read(t Format) []byte {
lock.Lock()
defer lock.Unlock()
buf, err := read(t)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
}
return nil
}
return buf
}
// Write writes a given buffer to the clipboard in a specified format.
// Write returned a receive-only channel can receive an empty struct
// as a signal, which indicates the clipboard has been overwritten from
// this write.
// If format t indicates an image, then the given buf assumes
// the image data is PNG encoded.
func Write(t Format, buf []byte) <-chan struct{} {
lock.Lock()
defer lock.Unlock()
changed, err := write(t, buf)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
}
return nil
}
return changed
}
// Watch returns a receive-only channel that received the clipboard data
// whenever any change of clipboard data in the desired format happens.
//
// The returned channel will be closed if the given context is canceled.
func Watch(ctx context.Context, t Format) <-chan []byte {
return watch(ctx, t)
}
@@ -0,0 +1,266 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
package clipboard
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
var (
lastChangeCount int64
changeCountMu sync.Mutex
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
return readText()
case FmtImage:
return readImage()
default:
return nil, errUnsupported
}
}
func readText() ([]byte, error) {
// Check if clipboard contains string data
checkScript := `
try
set clipboardTypes to (clipboard info)
repeat with aType in clipboardTypes
if (first item of aType) is string then
return "hastext"
end if
end repeat
return "notext"
on error
return "error"
end try
`
cmd := exec.Command("osascript", "-e", checkScript)
checkOut, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
checkOut = bytes.TrimSpace(checkOut)
if !bytes.Equal(checkOut, []byte("hastext")) {
return nil, errUnavailable
}
// Now get the actual text
cmd = exec.Command("osascript", "-e", "get the clipboard")
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Remove trailing newline that osascript adds
out = bytes.TrimSuffix(out, []byte("\n"))
// If clipboard was set to empty string, return nil
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func readImage() ([]byte, error) {
// AppleScript to read image data from clipboard as base64
script := `
try
set theData to the clipboard as «class PNGf»
return theData
on error
return ""
end try
`
cmd := exec.Command("osascript", "-e", script)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Check if we got any data
out = bytes.TrimSpace(out)
if len(out) == 0 {
return nil, errUnavailable
}
// The output is in hex format (e.g., «data PNGf89504E...»)
// We need to extract and convert it
outStr := string(out)
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
return nil, errUnavailable
}
// Extract hex data
hexData := strings.TrimPrefix(outStr, "«data PNGf")
hexData = strings.TrimSuffix(hexData, "»")
// Convert hex to bytes
buf := make([]byte, len(hexData)/2)
for i := 0; i < len(hexData); i += 2 {
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
if err != nil {
return nil, errUnavailable
}
buf[i/2] = byte(b)
}
return buf, nil
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
var err error
switch t {
case FmtText:
err = writeText(buf)
case FmtImage:
err = writeImage(buf)
default:
return nil, errUnsupported
}
if err != nil {
return nil, err
}
// Update change count
changeCountMu.Lock()
lastChangeCount++
currentCount := lastChangeCount
changeCountMu.Unlock()
// use unbuffered channel to prevent goroutine leak
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeCountMu.Lock()
if lastChangeCount != currentCount {
changeCountMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeCountMu.Unlock()
}
}()
return changed, nil
}
func writeText(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Escape the text for AppleScript
text := string(buf)
text = strings.ReplaceAll(text, "\\", "\\\\")
text = strings.ReplaceAll(text, "\"", "\\\"")
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func writeImage(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Create a temporary file to store the PNG data
tmpFile, err := os.CreateTemp("", "clipboard*.png")
if err != nil {
return errUnavailable
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(buf); err != nil {
tmpFile.Close()
return errUnavailable
}
tmpFile.Close()
// Use osascript to set clipboard to the image file
script := fmt.Sprintf(`
set theFile to POSIX file "%s"
set theImage to read theFile as «class PNGf»
set the clipboard to theImage
`, tmpFile.Name())
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}
@@ -0,0 +1,297 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
package clipboard
import (
"bytes"
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
)
var (
// Clipboard tools in order of preference
clipboardTools = []struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}{
{
name: "xclip",
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
writeCmd: []string{"xclip", "-selection", "clipboard"},
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
},
{
name: "xsel",
readCmd: []string{"xsel", "--clipboard", "--output"},
writeCmd: []string{"xsel", "--clipboard", "--input"},
readImg: []string{"xsel", "--clipboard", "--output"},
writeImg: []string{"xsel", "--clipboard", "--input"},
},
{
name: "wl-clipboard",
readCmd: []string{"wl-paste", "-n"},
writeCmd: []string{"wl-copy"},
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
writeImg: []string{"wl-copy", "-t", "image/png"},
},
}
selectedTool int = -1
toolMutex sync.Mutex
lastChangeTime time.Time
changeTimeMu sync.Mutex
)
func initialize() error {
toolMutex.Lock()
defer toolMutex.Unlock()
if selectedTool >= 0 {
return nil // Already initialized
}
// Check which clipboard tool is available
for i, tool := range clipboardTools {
cmd := exec.Command("which", tool.name)
if err := cmd.Run(); err == nil {
clipboardTools[i].available = true
if selectedTool < 0 {
selectedTool = i
slog.Debug("Clipboard tool found", "tool", tool.name)
}
}
}
if selectedTool < 0 {
slog.Warn("No clipboard utility found on system. Copy/paste functionality will be disabled.")
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
For X11 systems:
apt install -y xclip
# or
apt install -y xsel
For Wayland systems:
apt install -y wl-clipboard
If running in a headless environment, you may also need:
apt install -y xvfb
# and run:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0`, errUnavailable)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// Ensure clipboard is initialized before attempting to read
if err := initialize(); err != nil {
slog.Debug("Clipboard read failed: not initialized", "error", err)
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
switch t {
case FmtText:
return readText(tool)
case FmtImage:
return readImage(tool)
default:
return nil, errUnsupported
}
}
func readText(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
// First check if clipboard contains text
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
out, err := cmd.Output()
if err != nil {
// Check if it's because clipboard contains non-text data
if tool.name == "xclip" {
// xclip returns error when clipboard doesn't contain requested type
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
targets, _ := checkCmd.Output()
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
return nil, errUnavailable
}
}
return nil, errUnavailable
}
return out, nil
}
func readImage(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
if tool.name == "xsel" {
// xsel doesn't support image types well, return error
return nil, errUnavailable
}
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Verify it's PNG data
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
return nil, errUnavailable
}
return out, nil
}
func write(t Format, buf []byte) (<-chan struct{}, error) {
// Ensure clipboard is initialized before attempting to write
if err := initialize(); err != nil {
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
var cmd *exec.Cmd
switch t {
case FmtText:
if len(buf) == 0 {
// Write empty string
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
case FmtImage:
if tool.name == "xsel" {
// xsel doesn't support image types well
return nil, errUnavailable
}
if len(buf) == 0 {
// Clear clipboard
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
default:
return nil, errUnsupported
}
if err := cmd.Run(); err != nil {
return nil, errUnavailable
}
// Update change time
changeTimeMu.Lock()
lastChangeTime = time.Now()
currentTime := lastChangeTime
changeTimeMu.Unlock()
// Create change notification channel
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeTimeMu.Lock()
if !lastChangeTime.Equal(currentTime) {
changeTimeMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeTimeMu.Unlock()
}
}()
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
// Ensure clipboard is initialized before starting watch
if err := initialize(); err != nil {
close(recv)
return recv
}
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}
// Helper function to check clipboard content type for xclip
func getClipboardTargets() []string {
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
out, err := cmd.Output()
if err != nil {
return nil
}
return strings.Split(string(out), "\n")
}
@@ -0,0 +1,25 @@
//go:build !windows && !darwin && !linux && !cgo
package clipboard
import "context"
func initialize() error {
return errNoCgo
}
func read(t Format) (buf []byte, err error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func readc(t string) ([]byte, error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func write(t Format, buf []byte) (<-chan struct{}, error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func watch(ctx context.Context, t Format) <-chan []byte {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
@@ -0,0 +1,551 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
package clipboard
// Interacting with Clipboard on Windows:
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"reflect"
"runtime"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"golang.org/x/image/bmp"
)
func initialize() error { return nil }
// readText reads the clipboard and returns the text data if presents.
// The caller is responsible for opening/closing the clipboard before
// calling this function.
func readText() (buf []byte, err error) {
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
if hMem == 0 {
return nil, err
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// Find NUL terminator
n := 0
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
ptr = unsafe.Pointer(uintptr(ptr) +
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
}
var s []uint16
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
h.Data = p
h.Len = n
h.Cap = n
return []byte(string(utf16.Decode(s))), nil
}
// writeText writes given data to the clipboard. It is the caller's
// responsibility for opening/closing the clipboard before calling
// this function.
func writeText(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
s, err := syscall.UTF16FromString(string(buf))
if err != nil {
return fmt.Errorf("failed to convert given string: %w", err)
}
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
// no return value
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
// readImage reads the clipboard and returns PNG encoded image data
// if presents. The caller is responsible for opening/closing the
// clipboard before calling this function.
func readImage() ([]byte, error) {
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
if hMem == 0 {
// second chance to try FmtDIB
return readImageDib()
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// inspect header information
info := (*bitmapV5Header)(unsafe.Pointer(p))
// maybe deal with other formats?
if info.BitCount != 32 {
return nil, errUnsupported
}
var data []byte
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
sh.Data = uintptr(p)
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
offset := int(info.Size)
stride := int(info.Width)
for y := 0; y < int(info.Height); y++ {
for x := 0; x < int(info.Width); x++ {
idx := offset + 4*(y*stride+x)
xhat := (x + int(info.Width)) % int(info.Width)
yhat := int(info.Height) - 1 - y
r := data[idx+2]
g := data[idx+1]
b := data[idx+0]
a := data[idx+3]
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
}
}
// always use PNG encoding.
var buf bytes.Buffer
png.Encode(&buf, img)
return buf.Bytes(), nil
}
func readImageDib() ([]byte, error) {
const (
fileHeaderLen = 14
infoHeaderLen = 40
cFmtDIB = 8
)
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
if err != nil {
return nil, errors.New("not dib format data: " + err.Error())
}
pMemBlk, _, err := gLock.Call(hClipDat)
if pMemBlk == 0 {
return nil, errors.New("failed to call global lock: " + err.Error())
}
defer gUnlock.Call(hClipDat)
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
dataSize += iSizeImage
}
buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
binary.Write(buf, binary.LittleEndian, uint32(0))
const sizeof_colorbar = 0
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
j := 0
for i := fileHeaderLen; i < int(dataSize); i++ {
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
j++
}
return bmpToPng(buf)
}
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
var f bytes.Buffer
original_image, err := bmp.Decode(bmpBuf)
if err != nil {
return nil, err
}
err = png.Encode(&f, original_image)
if err != nil {
return nil, err
}
return f.Bytes(), nil
}
func writeImage(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(buf))
if err != nil {
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
}
offset := unsafe.Sizeof(bitmapV5Header{})
width := img.Bounds().Dx()
height := img.Bounds().Dy()
imageSize := 4 * width * height
data := make([]byte, int(offset)+imageSize)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
idx := int(offset) + 4*(y*width+x)
r, g, b, a := img.At(x, height-1-y).RGBA()
data[idx+2] = uint8(r)
data[idx+1] = uint8(g)
data[idx+0] = uint8(b)
data[idx+3] = uint8(a)
}
}
info := bitmapV5Header{}
info.Size = uint32(offset)
info.Width = int32(width)
info.Height = int32(height)
info.Planes = 1
info.Compression = 0 // BI_RGB
info.SizeImage = uint32(4 * info.Width * info.Height)
info.RedMask = 0xff0000 // default mask
info.GreenMask = 0xff00
info.BlueMask = 0xff
info.AlphaMask = 0xff000000
info.BitCount = 32 // we only deal with 32 bpp at the moment.
// Use calibrated RGB values as Go's image/png assumes linear color space.
// Other options:
// - LCS_CALIBRATED_RGB = 0x00000000
// - LCS_sRGB = 0x73524742
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
info.CSType = 0x73524742
// Use GL_IMAGES for GamutMappingIntent
// Other options:
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
// - LCS_GM_BUSINESS = 0x00000001
// - LCS_GM_GRAPHICS = 0x00000002
// - LCS_GM_IMAGES = 0x00000004
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
info.Intent = 4 // LCS_GM_IMAGES
infob := make([]byte, int(unsafe.Sizeof(info)))
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
infob[i] = v
}
copy(data[:], infob[:])
hMem, _, err := gAlloc.Call(gmemMoveable,
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// On Windows, OpenClipboard and CloseClipboard must be executed on
// the same thread. Thus, lock the OS thread for further execution.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var format uintptr
switch t {
case FmtImage:
format = cFmtDIBV5
case FmtText:
fallthrough
default:
format = cFmtUnicodeText
}
// check if clipboard is avaliable for the requested format
r, _, err := isClipboardFormatAvailable.Call(format)
if r == 0 {
return nil, errUnavailable
}
// try again until open clipboard successed
for {
r, _, _ = openClipboard.Call()
if r == 0 {
continue
}
break
}
defer closeClipboard.Call()
switch format {
case cFmtDIBV5:
return readImage()
case cFmtUnicodeText:
fallthrough
default:
return readText()
}
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
errch := make(chan error)
changed := make(chan struct{}, 1)
go func() {
// make sure GetClipboardSequenceNumber happens with
// OpenClipboard on the same thread.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for {
r, _, _ := openClipboard.Call(0)
if r == 0 {
continue
}
break
}
// var param uintptr
switch t {
case FmtImage:
err := writeImage(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
case FmtText:
fallthrough
default:
// param = cFmtUnicodeText
err := writeText(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
}
// Close the clipboard otherwise other applications cannot
// paste the data.
closeClipboard.Call()
cnt, _, _ := getClipboardSequenceNumber.Call()
errch <- nil
for {
time.Sleep(time.Second)
cur, _, _ := getClipboardSequenceNumber.Call()
if cur != cnt {
changed <- struct{}{}
close(changed)
return
}
}
}()
err := <-errch
if err != nil {
return nil, err
}
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ready := make(chan struct{})
go func() {
// not sure if we are too slow or the user too fast :)
ti := time.NewTicker(time.Second)
cnt, _, _ := getClipboardSequenceNumber.Call()
ready <- struct{}{}
for {
select {
case <-ctx.Done():
close(recv)
return
case <-ti.C:
cur, _, _ := getClipboardSequenceNumber.Call()
if cnt != cur {
b := Read(t)
if b == nil {
continue
}
recv <- b
cnt = cur
}
}
}
}()
<-ready
return recv
}
const (
cFmtBitmap = 2 // Win+PrintScreen
cFmtUnicodeText = 13
cFmtDIBV5 = 17
// Screenshot taken from special shortcut is in different format (why??), see:
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
gmemMoveable = 0x0002
)
// BITMAPV5Header structure, see:
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
type bitmapV5Header struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ClrUsed uint32
ClrImportant uint32
RedMask uint32
GreenMask uint32
BlueMask uint32
AlphaMask uint32
CSType uint32
Endpoints struct {
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
}
}
GammaRed uint32
GammaGreen uint32
GammaBlue uint32
Intent uint32
ProfileData uint32
ProfileSize uint32
Reserved uint32
}
type bitmapHeader struct {
Size uint32
Width uint32
Height uint32
PLanes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter uint32
YPelsPerMeter uint32
ClrUsed uint32
ClrImportant uint32
}
// Calling a Windows DLL, see:
// https://github.com/golang/go/wiki/WindowsDLLs
var (
user32 = syscall.MustLoadDLL("user32")
// Opens the clipboard for examination and prevents other
// applications from modifying the clipboard content.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
openClipboard = user32.MustFindProc("OpenClipboard")
// Closes the clipboard.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
closeClipboard = user32.MustFindProc("CloseClipboard")
// Empties the clipboard and frees handles to data in the clipboard.
// The function then assigns ownership of the clipboard to the
// window that currently has the clipboard open.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
emptyClipboard = user32.MustFindProc("EmptyClipboard")
// Retrieves data from the clipboard in a specified format.
// The clipboard must have been opened previously.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
getClipboardData = user32.MustFindProc("GetClipboardData")
// Places data on the clipboard in a specified clipboard format.
// The window must be the current clipboard owner, and the
// application must have called the OpenClipboard function. (When
// responding to the WM_RENDERFORMAT message, the clipboard owner
// must not call OpenClipboard before calling SetClipboardData.)
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
setClipboardData = user32.MustFindProc("SetClipboardData")
// Determines whether the clipboard contains data in the specified format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
// Clipboard data formats are stored in an ordered list. To perform
// an enumeration of clipboard data formats, you make a series of
// calls to the EnumClipboardFormats function. For each call, the
// format parameter specifies an available clipboard format, and the
// function returns the next available clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
// Retrieves the clipboard sequence number for the current window station.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
// Registers a new clipboard format. This format can then be used as
// a valid clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
kernel32 = syscall.NewLazyDLL("kernel32")
// Locks a global memory object and returns a pointer to the first
// byte of the object's memory block.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
gLock = kernel32.NewProc("GlobalLock")
// Decrements the lock count associated with a memory object that was
// allocated with GMEM_MOVEABLE. This function has no effect on memory
// objects allocated with GMEM_FIXED.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
gUnlock = kernel32.NewProc("GlobalUnlock")
// Allocates the specified number of bytes from the heap.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
gAlloc = kernel32.NewProc("GlobalAlloc")
// Frees the specified global memory object and invalidates its handle.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
gFree = kernel32.NewProc("GlobalFree")
memMove = kernel32.NewProc("RtlMoveMemory")
)
+30 -15
View File
@@ -29,7 +29,7 @@ type Command struct {
Name CommandName
Description string
Keybindings []Keybinding
Trigger string
Trigger []string
}
func (c Command) Keys() []string {
@@ -40,6 +40,21 @@ func (c Command) Keys() []string {
return keys
}
func (c Command) HasTrigger() bool {
return len(c.Trigger) > 0
}
func (c Command) PrimaryTrigger() string {
if len(c.Trigger) > 0 {
return c.Trigger[0]
}
return ""
}
func (c Command) MatchesTrigger(trigger string) bool {
return slices.Contains(c.Trigger, trigger)
}
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Sorted() []Command {
@@ -135,37 +150,37 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Name: AppHelpCommand,
Description: "show help",
Keybindings: parseBindings("<leader>h"),
Trigger: "help",
Trigger: []string{"help"},
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
Trigger: []string{"editor"},
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
Trigger: []string{"new", "clear"},
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
Trigger: []string{"sessions", "resume", "continue"},
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
Trigger: []string{"share"},
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: "unshare",
Trigger: []string{"unshare"},
},
{
Name: SessionInterruptCommand,
@@ -176,31 +191,31 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
Trigger: []string{"compact", "summarize"},
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
Trigger: []string{"details"},
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
Trigger: []string{"models"},
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
Trigger: []string{"themes"},
},
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
Trigger: []string{"files"},
},
{
Name: FileCloseCommand,
@@ -221,7 +236,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: "init",
Trigger: []string{"init"},
},
{
Name: InputClearCommand,
@@ -231,7 +246,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v"),
Keybindings: parseBindings("ctrl+v", "super+v"),
},
{
Name: InputSubmitCommand,
@@ -302,7 +317,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
Trigger: []string{"exit", "quit"},
},
}
registry := make(CommandRegistry)
+12 -9
View File
@@ -31,7 +31,7 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + 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,
@@ -45,8 +45,8 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Trigger) > space {
space = lipgloss.Width(cmd.Trigger)
if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
space = lipgloss.Width(cmd.PrimaryTrigger())
}
}
space += 2
@@ -56,10 +56,10 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range sorted {
if cmd.Trigger == "" {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
space := space - lipgloss.Width(cmd.PrimaryTrigger())
items = append(items, getCommandCompletionItem(cmd, space, t))
}
return items, nil
@@ -70,12 +70,15 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range sorted {
if cmd.Trigger == "" {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
space := space - lipgloss.Width(cmd.PrimaryTrigger())
// Add all triggers as searchable options
for _, trigger := range cmd.Trigger {
commandNames = append(commandNames, trigger)
commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
}
}
// Find fuzzy matches
@@ -42,7 +42,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
})
for _, file := range files {
title := file.File
title := file.Path
if file.Added > 0 {
title += green(" +" + strconv.Itoa(int(file.Added)))
}
@@ -51,7 +51,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: file.File,
Value: file.Path,
})
items = append(items, item)
}
+124 -34
View File
@@ -1,9 +1,12 @@
package chat
import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
@@ -12,10 +15,10 @@ import (
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -27,6 +30,7 @@ type EditorComponent interface {
Content(width int) string
Lines() int
Value() string
Length() int
Focused() bool
Focus() (tea.Model, tea.Cmd)
Blur()
@@ -35,6 +39,7 @@ type EditorComponent interface {
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
@@ -42,6 +47,7 @@ type editorComponent struct {
textarea textarea.Model
spinner spinner.Model
interruptKeyInDebounce bool
exitKeyInDebounce bool
}
func (m *editorComponent) Init() tea.Cmd {
@@ -63,8 +69,74 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
case tea.PasteMsg:
text := string(msg)
text = strings.ReplaceAll(text, "\\", "")
text, err := strconv.Unquote(`"` + text + `"`)
if err != nil {
slog.Error("Failed to unquote text", "error", err)
m.textarea.InsertRunesFromUserInput([]rune(msg))
return m, nil
}
if _, err := os.Stat(text); err != nil {
slog.Error("Failed to paste file", "error", err)
m.textarea.InsertRunesFromUserInput([]rune(msg))
return m, nil
}
filePath := text
ext := strings.ToLower(filepath.Ext(filePath))
mediaType := ""
switch ext {
case ".jpg":
mediaType = "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
mediaType = "image/" + ext[1:]
case ".pdf":
mediaType = "application/pdf"
default:
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)
if err != nil {
slog.Error("Failed to read file", "error", err)
m.textarea.InsertRunesFromUserInput([]rune(msg))
return m, nil
}
base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
label := "File"
if strings.HasPrefix(mediaType, "image/") {
label = "Image"
}
attachment := &textarea.Attachment{
ID: uuid.NewString(),
MediaType: mediaType,
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
URL: url,
Filename: filePath,
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
case tea.ClipboardMsg:
text := string(msg)
m.textarea.InsertRunesFromUserInput([]rune(text))
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.textarea = m.resetTextareaStyles()
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
@@ -164,7 +236,10 @@ func (m *editorComponent) Content(width int) string {
Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.app.IsBusy() {
if m.exitKeyInDebounce {
keyText := m.getExitKeyText()
hint = base(keyText+" again") + muted(" to exit")
} else if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted(
@@ -230,6 +305,10 @@ func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func (m *editorComponent) Length() int {
return m.textarea.Length()
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := strings.TrimSpace(m.Value())
if value == "" {
@@ -269,24 +348,31 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
_, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
imageBytes := clipboard.Read(clipboard.FmtImage)
if imageBytes != nil {
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
attachment := &textarea.Attachment{
ID: uuid.NewString(),
MediaType: "image/png",
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
// if len(imageBytes) != 0 {
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
// attachment := app.Attachment{
// FilePath: attachmentName,
// FileName: attachmentName,
// Content: imageBytes,
// MimeType: "image/png",
// }
// m.attachments = append(m.attachments, attachment)
// } else {
m.textarea.InsertString(text)
// }
return m, nil
textBytes := clipboard.Read(clipboard.FmtText)
if textBytes != nil {
m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
return m, nil
}
// fallback to reading the clipboard using OSC52
return m, tea.ReadClipboard
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
@@ -298,6 +384,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
m.exitKeyInDebounce = inDebounce
}
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
@@ -306,13 +396,17 @@ func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func createTextArea(existing *textarea.Model) textarea.Model {
func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0]
}
func (m *editorComponent) resetTextareaStyles() textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
ta := m.textarea
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
@@ -337,17 +431,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
Background(t.Secondary()).
Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
// ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
return ta
}
@@ -367,12 +450,19 @@ func createSpinner() spinner.Model {
func NewEditorComponent(app *app.App) EditorComponent {
s := createSpinner()
ta := createTextArea(nil)
return &editorComponent{
ta := textarea.New()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
m := &editorComponent{
app: app,
textarea: ta,
spinner: s,
interruptKeyInDebounce: false,
}
m.resetTextareaStyles()
return m
}
@@ -134,7 +134,6 @@ func renderContentBlock(
style := styles.NewStyle().
Foreground(renderer.textColor).
Background(t.BackgroundPanel()).
Width(width).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
@@ -232,16 +231,15 @@ func renderText(
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
content := messageStyle.Render(text)
var content string
switch casted := message.(type) {
case opencode.AssistantMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
messageStyle = messageStyle.Width(width - 6)
messageStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6)
content = messageStyle.Render(text)
}
timestamp := ts.
@@ -307,8 +305,10 @@ func renderToolDetails(
return ""
}
if toolCall.State.Status == opencode.ToolPartStateStatusPending || toolCall.State.Status == opencode.ToolPartStateStatusRunning {
if toolCall.State.Status == opencode.ToolPartStateStatusPending ||
toolCall.State.Status == opencode.ToolPartStateStatusRunning {
title := renderToolTitle(toolCall, width)
title = styles.NewStyle().Width(width - 6).Render(title)
return renderContentBlock(app, title, highlight, width)
}
@@ -422,6 +422,8 @@ func renderToolDetails(
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
body += fmt.Sprintf("- [~] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
@@ -455,6 +457,7 @@ func renderToolDetails(
}
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
}
}
@@ -465,6 +468,7 @@ func renderToolDetails(
if error != "" {
body = styles.NewStyle().
Width(width - 6).
Foreground(t.Error()).
Background(backgroundColor).
Render(error)
@@ -473,6 +477,7 @@ func renderToolDetails(
if body == "" && error == "" && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
}
title := renderToolTitle(toolCall, width)
@@ -320,12 +320,15 @@ func (m *messagesComponent) renderView(width int) {
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.MessageAbortedError:
error = "Request was aborted"
case opencode.UnknownError:
error = err.Data.Message
}
}
if error != "" {
error = styles.NewStyle().Width(width - 6).Render(error)
error = renderContentBlock(
m.app,
error,
@@ -56,8 +56,8 @@ func (c *commandsComponent) View() string {
var untriggeredCommands []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if c.showAll || cmd.Trigger != "" {
if cmd.Trigger != "" {
if c.showAll || cmd.HasTrigger() {
if cmd.HasTrigger() {
triggeredCommands = append(triggeredCommands, cmd)
} else if c.showAll {
untriggeredCommands = append(untriggeredCommands, cmd)
@@ -97,8 +97,8 @@ func (c *commandsComponent) View() string {
for _, cmd := range commandsToShow {
trigger := ""
if cmd.Trigger != "" {
trigger = "/" + cmd.Trigger
if cmd.HasTrigger() {
trigger = "/" + cmd.PrimaryTrigger()
} else {
trigger = string(cmd.Name)
}
@@ -2,6 +2,7 @@ package dialog
import (
"log/slog"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
@@ -87,6 +88,7 @@ type completionDialogComponent struct {
height int
pseudoSearchTextArea textarea.Model
list list.List[CompletionItemI]
trigger string
}
type completionDialogKeyMap struct {
@@ -119,8 +121,8 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
var query string
query = c.pseudoSearchTextArea.Value()
fullValue := c.pseudoSearchTextArea.Value()
query := strings.TrimPrefix(fullValue, c.trigger)
if query != c.query {
c.query = query
@@ -147,8 +149,9 @@ 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
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
// 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) {
return c, c.close()
}
}
@@ -227,7 +230,7 @@ func (c *completionDialogComponent) close() tea.Cmd {
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
ti := textarea.New()
li := list.NewListComponent(
@@ -245,10 +248,14 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
li.SetItems(items)
}()
// Initialize the textarea with the trigger character
ti.SetValue(trigger)
return &completionDialogComponent{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
list: li,
trigger: trigger,
}
}
@@ -11,7 +11,6 @@ import (
"slices"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -653,12 +652,12 @@ func (m *Model) SetValue(s string) {
// InsertString inserts a string at the cursor position.
func (m *Model) InsertString(s string) {
m.insertRunesFromUserInput([]rune(s))
m.InsertRunesFromUserInput([]rune(s))
}
// InsertRune inserts a rune at the cursor position.
func (m *Model) InsertRune(r rune) {
m.insertRunesFromUserInput([]rune{r})
m.InsertRunesFromUserInput([]rune{r})
}
// InsertAttachment inserts an attachment at the cursor position.
@@ -730,8 +729,8 @@ func (m Model) GetAttachments() []*Attachment {
return attachments
}
// insertRunesFromUserInput inserts runes at the current cursor position.
func (m *Model) insertRunesFromUserInput(runes []rune) {
// InsertRunesFromUserInput inserts runes at the current cursor position.
func (m *Model) InsertRunesFromUserInput(runes []rune) {
// Clean up any special characters in the input provided by the
// clipboard. This avoids bugs due to e.g. tab characters and
// whatnot.
@@ -1429,8 +1428,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
switch msg := msg.(type) {
case tea.PasteMsg:
m.insertRunesFromUserInput([]rune(msg))
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
@@ -1490,8 +1487,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.CursorDown()
case key.Matches(msg, m.KeyMap.WordForward):
m.wordRight()
case key.Matches(msg, m.KeyMap.Paste):
return m, Paste
case key.Matches(msg, m.KeyMap.CharacterBackward):
m.characterLeft(false /* insideLine */)
case key.Matches(msg, m.KeyMap.LinePrevious):
@@ -1512,11 +1507,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.transposeLeft()
default:
m.insertRunesFromUserInput([]rune(msg.Text))
m.InsertRunesFromUserInput([]rune(msg.Text))
}
case pasteMsg:
m.insertRunesFromUserInput([]rune(msg))
m.InsertRunesFromUserInput([]rune(msg))
case pasteErrMsg:
m.Err = msg
@@ -1908,15 +1903,6 @@ func (m *Model) splitLine(row, col int) {
m.row++
}
// Paste is a command for pasting from the clipboard into the text input.
func Paste() tea.Msg {
str, err := clipboard.ReadAll()
if err != nil {
return pasteErrMsg{err}
}
return pasteMsg(str)
}
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}
@@ -1,46 +0,0 @@
//go:build !windows
package image
import (
"bytes"
"fmt"
"github.com/atotto/clipboard"
"image"
)
func GetImageFromClipboard() ([]byte, string, error) {
text, err := clipboard.ReadAll()
if err != nil {
return nil, "", fmt.Errorf("Error reading clipboard")
}
if text == "" {
return nil, "", nil
}
binaryData := []byte(text)
imageBytes, err := binaryToImage(binaryData)
if err != nil {
return nil, text, nil
}
return imageBytes, "", nil
}
func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, fmt.Errorf("Unable to covert bytes to image")
}
return ImageToBytes(img)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -1,192 +0,0 @@
//go:build windows
package image
import (
"bytes"
"fmt"
"image"
"image/color"
"log/slog"
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
openClipboard = user32.NewProc("OpenClipboard")
closeClipboard = user32.NewProc("CloseClipboard")
getClipboardData = user32.NewProc("GetClipboardData")
isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
globalLock = kernel32.NewProc("GlobalLock")
globalUnlock = kernel32.NewProc("GlobalUnlock")
globalSize = kernel32.NewProc("GlobalSize")
)
const (
CF_TEXT = 1
CF_UNICODETEXT = 13
CF_DIB = 8
)
type BITMAPINFOHEADER struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
func GetImageFromClipboard() ([]byte, string, error) {
ret, _, _ := openClipboard.Call(0)
if ret == 0 {
return nil, "", fmt.Errorf("failed to open clipboard")
}
defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
_, _, err := closeClipboard.Call(a...)
if err != nil {
slog.Error("close clipboard failed")
return
}
}(closeClipboard)
isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
// Get text from clipboard
var formatToUse uintptr = CF_TEXT
if isUnicodeTextAvailable != 0 {
formatToUse = CF_UNICODETEXT
}
hClipboardText, _, _ := getClipboardData.Call(formatToUse)
if hClipboardText != 0 {
textPtr, _, _ := globalLock.Call(hClipboardText)
if textPtr != 0 {
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
_, _, err := globalUnlock.Call(a...)
if err != nil {
slog.Error("Global unlock failed")
return
}
}(globalUnlock, hClipboardText)
// Get clipboard text
var clipboardText string
if formatToUse == CF_UNICODETEXT {
// Convert wide string to Go string
clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
} else {
// Get size of ANSI text
size, _, _ := globalSize.Call(hClipboardText)
if size > 0 {
// Convert ANSI string to Go string
textBytes := make([]byte, size)
copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
clipboardText = bytesToString(textBytes)
}
}
// Check if the text is not empty
if clipboardText != "" {
return nil, clipboardText, nil
}
}
}
}
hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
if hClipboardData == 0 {
return nil, "", fmt.Errorf("failed to get clipboard data")
}
dataPtr, _, _ := globalLock.Call(hClipboardData)
if dataPtr == 0 {
return nil, "", fmt.Errorf("failed to lock clipboard data")
}
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
_, _, err := globalUnlock.Call(a...)
if err != nil {
slog.Error("Global unlock failed")
return
}
}(globalUnlock, hClipboardData)
bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
width := int(bmiHeader.BiWidth)
height := int(bmiHeader.BiHeight)
if height < 0 {
height = -height
}
bitsPerPixel := int(bmiHeader.BiBitCount)
img := image.NewRGBA(image.Rect(0, 0, width, height))
var bitsOffset uintptr
if bitsPerPixel <= 8 {
numColors := uint32(1) << bitsPerPixel
if bmiHeader.BiClrUsed > 0 {
numColors = bmiHeader.BiClrUsed
}
bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
} else {
bitsOffset = unsafe.Sizeof(*bmiHeader)
}
for y := range height {
for x := range width {
srcY := height - y - 1
if bmiHeader.BiHeight < 0 {
srcY = y
}
var pixelPointer unsafe.Pointer
var r, g, b, a uint8
switch bitsPerPixel {
case 24:
stride := (width*3 + 3) &^ 3
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
b = *(*byte)(pixelPointer)
g = *(*byte)(unsafe.Add(pixelPointer, 1))
r = *(*byte)(unsafe.Add(pixelPointer, 2))
a = 255
case 32:
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
b = *(*byte)(pixelPointer)
g = *(*byte)(unsafe.Add(pixelPointer, 1))
r = *(*byte)(unsafe.Add(pixelPointer, 2))
a = *(*byte)(unsafe.Add(pixelPointer, 3))
if a == 0 {
a = 255
}
default:
return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
}
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
}
}
imageBytes, err := ImageToBytes(img)
if err != nil {
return nil, "", err
}
return imageBytes, "", nil
}
func bytesToString(b []byte) string {
i := bytes.IndexByte(b, 0)
if i == -1 {
return string(b)
}
return string(b[:i])
}
-86
View File
@@ -1,86 +0,0 @@
package image
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/disintegration/imaging"
"github.com/lucasb-eyer/go-colorful"
_ "golang.org/x/image/webp"
)
func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return false, fmt.Errorf("error getting file info: %w", err)
}
if fileInfo.Size() > sizeLimit {
return true, nil
}
return false, nil
}
func ToString(width int, img image.Image) string {
img = imaging.Resize(img, width, 0, imaging.Lanczos)
b := img.Bounds()
imageWidth := b.Max.X
h := b.Max.Y
str := strings.Builder{}
for heightCounter := 0; heightCounter < h; heightCounter += 2 {
for x := range imageWidth {
c1, _ := colorful.MakeColor(img.At(x, heightCounter))
color1 := lipgloss.Color(c1.Hex())
var color2 color.Color
if heightCounter+1 < h {
c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
color2 = lipgloss.Color(c2.Hex())
} else {
color2 = color1
}
str.WriteString(lipgloss.NewStyle().Foreground(color1).
Background(color2).Render("▀"))
}
str.WriteString("\n")
}
return str.String()
}
func ImagePreview(width int, filename string) (string, error) {
imageContent, err := os.Open(filename)
if err != nil {
return "", err
}
defer imageContent.Close()
img, _, err := image.Decode(imageContent)
if err != nil {
return "", err
}
imageString := ToString(width, img)
return imageString, nil
}
func ImageToBytes(image image.Image) ([]byte, error) {
buf := new(bytes.Buffer)
err := png.Encode(buf, image)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+51 -9
View File
@@ -32,15 +32,27 @@ import (
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
type InterruptDebounceTimeoutMsg struct{}
// ExitDebounceTimeoutMsg is sent when the exit key debounce timeout expires
type ExitDebounceTimeoutMsg struct{}
// InterruptKeyState tracks the state of interrupt key presses for debouncing
type InterruptKeyState int
// ExitKeyState tracks the state of exit key presses for debouncing
type ExitKeyState int
const (
InterruptKeyIdle InterruptKeyState = iota
InterruptKeyFirstPress
)
const (
ExitKeyIdle ExitKeyState = iota
ExitKeyFirstPress
)
const interruptDebounceTimeout = 1 * time.Second
const exitDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 160
type appModel struct {
@@ -59,6 +71,7 @@ type appModel struct {
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
@@ -189,7 +202,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)
@@ -208,7 +221,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
// Set file provider for file completion
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@")
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -252,7 +265,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
// 6. Handle interrupt key debounce for session interrupt
// 6 Handle input clear command
inputClearCommand := a.app.Commands[commands.InputClearCommand]
if inputClearCommand.Matches(msg, a.isLeaderSequence) && a.editor.Length() > 0 {
return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
}
// 7. Handle interrupt key debounce for session interrupt
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
switch a.interruptKeyState {
@@ -271,7 +290,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// 7. Check again for commands that don't require leader (excluding interrupt when busy)
// 8. Handle exit key debounce for app exit when using non-leader command
exitCommand := a.app.Commands[commands.AppExitCommand]
if exitCommand.Matches(msg, a.isLeaderSequence) {
switch a.exitKeyState {
case ExitKeyIdle:
// First exit key press - start debounce timer
a.exitKeyState = ExitKeyFirstPress
a.editor.SetExitKeyInDebounce(true)
return a, tea.Tick(exitDebounceTimeout, func(t time.Time) tea.Msg {
return ExitDebounceTimeoutMsg{}
})
case ExitKeyFirstPress:
// Second exit key press within timeout - actually exit
a.exitKeyState = ExitKeyIdle
a.editor.SetExitKeyInDebounce(false)
return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand))
}
}
// 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 {
// Skip interrupt key if we're in debounce mode and app is busy
@@ -281,8 +319,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
// 7. Fallback to editor. This is for other characters
// like backspace, tab, etc.
// 10. Fallback to editor. This is for other characters like backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
@@ -499,6 +536,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
case ExitDebounceTimeoutMsg:
// Reset exit key state after timeout
a.exitKeyState = ExitKeyIdle
a.editor.SetExitKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
}
@@ -841,7 +882,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, toast.NewErrorToast("Failed to share session")
}
shareUrl := response.Share.URL
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, a.app.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionUnshareCommand:
if a.app.Session.ID == "" {
@@ -975,7 +1016,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.MessagesCopyCommand:
selected := a.messages.Selected()
if selected != "" {
cmd = tea.SetClipboard(selected)
cmd = a.app.SetClipboard(selected)
cmds = append(cmds, cmd)
cmd = toast.NewSuccessToast("Message copied to clipboard")
cmds = append(cmds, cmd)
@@ -993,7 +1034,7 @@ func NewModel(app *app.App) tea.Model {
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 != "" {
@@ -1015,6 +1056,7 @@ func NewModel(app *app.App) tea.Model {
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
exitKeyState: ExitKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
}
+123
View File
@@ -0,0 +1,123 @@
package util
import (
"context"
"log/slog"
"sync"
opencode "github.com/sst/opencode-sdk-go"
)
type APILogHandler struct {
client *opencode.Client
service string
level slog.Level
attrs []slog.Attr
groups []string
mu sync.Mutex
}
func NewAPILogHandler(client *opencode.Client, service string, level slog.Level) *APILogHandler {
return &APILogHandler{
client: client,
service: service,
level: level,
attrs: make([]slog.Attr, 0),
groups: make([]string, 0),
}
}
func (h *APILogHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *APILogHandler) Handle(ctx context.Context, r slog.Record) error {
var apiLevel opencode.AppLogParamsLevel
switch r.Level {
case slog.LevelDebug:
apiLevel = opencode.AppLogParamsLevelDebug
case slog.LevelInfo:
apiLevel = opencode.AppLogParamsLevelInfo
case slog.LevelWarn:
apiLevel = opencode.AppLogParamsLevelWarn
case slog.LevelError:
apiLevel = opencode.AppLogParamsLevelError
default:
apiLevel = opencode.AppLogParamsLevelInfo
}
extra := make(map[string]any)
h.mu.Lock()
for _, attr := range h.attrs {
extra[attr.Key] = attr.Value.Any()
}
h.mu.Unlock()
r.Attrs(func(attr slog.Attr) bool {
extra[attr.Key] = attr.Value.Any()
return true
})
params := opencode.AppLogParams{
Service: opencode.F(h.service),
Level: opencode.F(apiLevel),
Message: opencode.F(r.Message),
}
if len(extra) > 0 {
params.Extra = opencode.F(extra)
}
go func() {
_, err := h.client.App.Log(context.Background(), params)
if err != nil {
// Fallback: we can't log the error using slog as it would create a loop
// TODO: fallback file?
}
}()
return nil
}
// WithAttrs returns a new Handler whose attributes consist of
// both the receiver's attributes and the arguments.
func (h *APILogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
h.mu.Lock()
defer h.mu.Unlock()
newHandler := &APILogHandler{
client: h.client,
service: h.service,
level: h.level,
attrs: make([]slog.Attr, len(h.attrs)+len(attrs)),
groups: make([]string, len(h.groups)),
}
copy(newHandler.attrs, h.attrs)
copy(newHandler.attrs[len(h.attrs):], attrs)
copy(newHandler.groups, h.groups)
return newHandler
}
// WithGroup returns a new Handler with the given group appended to
// the receiver's existing groups.
func (h *APILogHandler) WithGroup(name string) slog.Handler {
h.mu.Lock()
defer h.mu.Unlock()
newHandler := &APILogHandler{
client: h.client,
service: h.service,
level: h.level,
attrs: make([]slog.Attr, len(h.attrs)),
groups: make([]string, len(h.groups)+1),
}
copy(newHandler.attrs, h.attrs)
copy(newHandler.groups, h.groups)
newHandler.groups[len(h.groups)] = name
return newHandler
}
+7 -7
View File
@@ -2,15 +2,15 @@ name: CI
on:
push:
branches-ignore:
- "generated"
- "codegen/**"
- "integrated/**"
- "stl-preview-head/**"
- "stl-preview-base/**"
- 'generated'
- 'codegen/**'
- 'integrated/**'
- 'stl-preview-head/**'
- 'stl-preview-base/**'
pull_request:
branches-ignore:
- "stl-preview-head/**"
- "stl-preview-base/**"
- 'stl-preview-head/**'
- 'stl-preview-base/**'
jobs:
lint:
@@ -1,3 +1,3 @@
{
".": "0.1.0-alpha.8"
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
configured_endpoints: 20
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-15eeb028f79b9a065b4e54a6ea6a58631e9bd5004f97820f0c79d18e3f8bac84.yml
openapi_spec_hash: 38c8bacb6c8e4c46852a3e81e3fb9fda
config_hash: e03e9d1aad76081fa1163086e89f201b
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
+13 -12
View File
@@ -6,7 +6,7 @@ Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519))
* **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519))
## 0.1.0-alpha.7 (2025-06-30)
@@ -14,12 +14,13 @@ Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775))
- **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf))
* **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775))
* **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf))
### Chores
- **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d))
* **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d))
## 0.1.0-alpha.6 (2025-06-28)
@@ -27,7 +28,7 @@ Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencod
### Bug Fixes
- don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d))
* don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d))
## 0.1.0-alpha.5 (2025-06-27)
@@ -35,7 +36,7 @@ Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e))
* **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e))
## 0.1.0-alpha.4 (2025-06-27)
@@ -43,7 +44,7 @@ Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52))
* **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52))
## 0.1.0-alpha.3 (2025-06-27)
@@ -51,7 +52,7 @@ Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61))
* **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61))
## 0.1.0-alpha.2 (2025-06-27)
@@ -59,7 +60,7 @@ Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6))
* **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6))
## 0.1.0-alpha.1 (2025-06-27)
@@ -67,6 +68,6 @@ Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencod
### Features
- **api:** update via SDK Studio ([27b7376](https://github.com/sst/opencode-sdk-go/commit/27b7376310466ee17a63f2104f546b53a2b8361a))
- **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397))
- **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844))
* **api:** update via SDK Studio ([27b7376](https://github.com/sst/opencode-sdk-go/commit/27b7376310466ee17a63f2104f546b53a2b8361a))
* **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397))
* **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844))
+9
View File
@@ -9,6 +9,15 @@ import (
type Error = apierror.Error
// This is an alias to an internal type.
type MessageAbortedError = shared.MessageAbortedError
// This is an alias to an internal type.
type MessageAbortedErrorName = shared.MessageAbortedErrorName
// This is an alias to an internal value.
const MessageAbortedErrorNameMessageAbortedError = shared.MessageAbortedErrorNameMessageAbortedError
// This is an alias to an internal type.
type ProviderAuthError = shared.ProviderAuthError
+9 -6
View File
@@ -1,5 +1,6 @@
# Shared Response Types
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared">shared</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared#MessageAbortedError">MessageAbortedError</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared">shared</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared#ProviderAuthError">ProviderAuthError</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared">shared</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared#UnknownError">UnknownError</a>
@@ -18,36 +19,38 @@ Methods:
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>
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>
# Find
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#FindSymbolsResponse">FindSymbolsResponse</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#FindTextResponse">FindTextResponse</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#Match">Match</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#Symbol">Symbol</a>
Methods:
- <code title="get /find/file">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Files">Files</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FindFilesParams">FindFilesParams</a>) ([]<a href="https://pkg.go.dev/builtin#string">string</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find/symbol">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Symbols">Symbols</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FindSymbolsParams">FindSymbolsParams</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#FindSymbolsResponse">FindSymbolsResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Text">Text</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FindTextParams">FindTextParams</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#FindTextResponse">FindTextResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find/symbol">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Symbols">Symbols</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FindSymbolsParams">FindSymbolsParams</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#Symbol">Symbol</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Text">Text</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FindTextParams">FindTextParams</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#Match">Match</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# File
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#File">File</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#FileReadResponse">FileReadResponse</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#FileStatusResponse">FileStatusResponse</a>
Methods:
- <code title="get /file">client.File.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileService.Read">Read</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <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#FileReadParams">FileReadParams</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#FileReadResponse">FileReadResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /file/status">client.File.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileService.Status">Status</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#FileStatusResponse">FileStatusResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /file/status">client.File.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileService.Status">Status</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#File">File</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Config
+60
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/sst/opencode-sdk-go/internal/apijson"
"github.com/sst/opencode-sdk-go/internal/param"
"github.com/sst/opencode-sdk-go/internal/requestconfig"
"github.com/sst/opencode-sdk-go/option"
)
@@ -46,6 +47,14 @@ func (r *AppService) Init(ctx context.Context, opts ...option.RequestOption) (re
return
}
// Write a log entry to the server logs
func (r *AppService) Log(ctx context.Context, body AppLogParams, opts ...option.RequestOption) (res *bool, err error) {
opts = append(r.Options[:], opts...)
path := "log"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
return
}
type App struct {
Git bool `json:"git,required"`
Hostname string `json:"hostname,required"`
@@ -121,3 +130,54 @@ func (r *AppTime) UnmarshalJSON(data []byte) (err error) {
func (r appTimeJSON) RawJSON() string {
return r.raw
}
// Log level
type LogLevel string
const (
LogLevelDebug LogLevel = "DEBUG"
LogLevelInfo LogLevel = "INFO"
LogLevelWarn LogLevel = "WARN"
LogLevelError LogLevel = "ERROR"
)
func (r LogLevel) IsKnown() bool {
switch r {
case LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError:
return true
}
return false
}
type AppLogParams struct {
// Log level
Level param.Field[AppLogParamsLevel] `json:"level,required"`
// Log message
Message param.Field[string] `json:"message,required"`
// Service name for the log entry
Service param.Field[string] `json:"service,required"`
// Additional metadata for the log entry
Extra param.Field[map[string]interface{}] `json:"extra"`
}
func (r AppLogParams) MarshalJSON() (data []byte, err error) {
return apijson.MarshalRoot(r)
}
// Log level
type AppLogParamsLevel string
const (
AppLogParamsLevelDebug AppLogParamsLevel = "debug"
AppLogParamsLevelInfo AppLogParamsLevel = "info"
AppLogParamsLevelError AppLogParamsLevel = "error"
AppLogParamsLevelWarn AppLogParamsLevel = "warn"
)
func (r AppLogParamsLevel) IsKnown() bool {
switch r {
case AppLogParamsLevelDebug, AppLogParamsLevelInfo, AppLogParamsLevelError, AppLogParamsLevelWarn:
return true
}
return false
}
+29
View File
@@ -56,3 +56,32 @@ func TestAppInit(t *testing.T) {
t.Fatalf("err should be nil: %s", err.Error())
}
}
func TestAppLogWithOptionalParams(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.Log(context.TODO(), opencode.AppLogParams{
Level: opencode.F(opencode.AppLogParamsLevelDebug),
Message: opencode.F("message"),
Service: opencode.F("service"),
Extra: opencode.F(map[string]interface{}{
"foo": "bar",
}),
})
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())
}
}
+57 -36
View File
@@ -62,6 +62,8 @@ type Config struct {
Instructions []string `json:"instructions"`
// Custom keybind configurations
Keybinds Keybinds `json:"keybinds"`
// 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"`
// Model to use in the format of provider/model, eg anthropic/claude-2
@@ -82,6 +84,7 @@ type configJSON struct {
Experimental apijson.Field
Instructions apijson.Field
Keybinds apijson.Field
LogLevel apijson.Field
Mcp apijson.Field
Model apijson.Field
Provider apijson.Field
@@ -396,82 +399,99 @@ func (r configProviderModelsLimitJSON) RawJSON() string {
type Keybinds struct {
// Exit the application
AppExit string `json:"app_exit"`
// Open external editor
EditorOpen string `json:"editor_open"`
AppExit string `json:"app_exit,required"`
// Show help dialog
Help string `json:"help"`
// Navigate to next history item
HistoryNext string `json:"history_next"`
// Navigate to previous history item
HistoryPrevious string `json:"history_previous"`
AppHelp string `json:"app_help,required"`
// Open external editor
EditorOpen string `json:"editor_open,required"`
// Close file
FileClose string `json:"file_close,required"`
// Split/unified diff
FileDiffToggle string `json:"file_diff_toggle,required"`
// List files
FileList string `json:"file_list,required"`
// Search file
FileSearch string `json:"file_search,required"`
// Clear input field
InputClear string `json:"input_clear"`
InputClear string `json:"input_clear,required"`
// Insert newline in input
InputNewline string `json:"input_newline"`
InputNewline string `json:"input_newline,required"`
// Paste from clipboard
InputPaste string `json:"input_paste"`
InputPaste string `json:"input_paste,required"`
// Submit input
InputSubmit string `json:"input_submit"`
InputSubmit string `json:"input_submit,required"`
// Leader key for keybind combinations
Leader string `json:"leader"`
Leader string `json:"leader,required"`
// Copy message
MessagesCopy string `json:"messages_copy,required"`
// Navigate to first message
MessagesFirst string `json:"messages_first"`
MessagesFirst string `json:"messages_first,required"`
// Scroll messages down by half page
MessagesHalfPageDown string `json:"messages_half_page_down"`
MessagesHalfPageDown string `json:"messages_half_page_down,required"`
// Scroll messages up by half page
MessagesHalfPageUp string `json:"messages_half_page_up"`
MessagesHalfPageUp string `json:"messages_half_page_up,required"`
// Navigate to last message
MessagesLast string `json:"messages_last"`
MessagesLast string `json:"messages_last,required"`
// Toggle layout
MessagesLayoutToggle string `json:"messages_layout_toggle,required"`
// Navigate to next message
MessagesNext string `json:"messages_next"`
MessagesNext string `json:"messages_next,required"`
// Scroll messages down by one page
MessagesPageDown string `json:"messages_page_down"`
MessagesPageDown string `json:"messages_page_down,required"`
// Scroll messages up by one page
MessagesPageUp string `json:"messages_page_up"`
MessagesPageUp string `json:"messages_page_up,required"`
// Navigate to previous message
MessagesPrevious string `json:"messages_previous"`
MessagesPrevious string `json:"messages_previous,required"`
// Revert message
MessagesRevert string `json:"messages_revert,required"`
// List available models
ModelList string `json:"model_list"`
// Initialize project configuration
ProjectInit string `json:"project_init"`
// Toggle compact mode for session
SessionCompact string `json:"session_compact"`
ModelList string `json:"model_list,required"`
// Create/update AGENTS.md
ProjectInit string `json:"project_init,required"`
// Compact the session
SessionCompact string `json:"session_compact,required"`
// Interrupt current session
SessionInterrupt string `json:"session_interrupt"`
SessionInterrupt string `json:"session_interrupt,required"`
// List all sessions
SessionList string `json:"session_list"`
SessionList string `json:"session_list,required"`
// Create a new session
SessionNew string `json:"session_new"`
SessionNew string `json:"session_new,required"`
// Share current session
SessionShare string `json:"session_share"`
SessionShare string `json:"session_share,required"`
// Unshare current session
SessionUnshare string `json:"session_unshare,required"`
// List available themes
ThemeList string `json:"theme_list"`
// Show tool details
ToolDetails string `json:"tool_details"`
ThemeList string `json:"theme_list,required"`
// Toggle tool details
ToolDetails string `json:"tool_details,required"`
JSON keybindsJSON `json:"-"`
}
// keybindsJSON contains the JSON metadata for the struct [Keybinds]
type keybindsJSON struct {
AppExit apijson.Field
AppHelp apijson.Field
EditorOpen apijson.Field
Help apijson.Field
HistoryNext apijson.Field
HistoryPrevious apijson.Field
FileClose apijson.Field
FileDiffToggle apijson.Field
FileList apijson.Field
FileSearch apijson.Field
InputClear apijson.Field
InputNewline apijson.Field
InputPaste apijson.Field
InputSubmit apijson.Field
Leader apijson.Field
MessagesCopy apijson.Field
MessagesFirst apijson.Field
MessagesHalfPageDown apijson.Field
MessagesHalfPageUp apijson.Field
MessagesLast apijson.Field
MessagesLayoutToggle apijson.Field
MessagesNext apijson.Field
MessagesPageDown apijson.Field
MessagesPageUp apijson.Field
MessagesPrevious apijson.Field
MessagesRevert apijson.Field
ModelList apijson.Field
ProjectInit apijson.Field
SessionCompact apijson.Field
@@ -479,6 +499,7 @@ type keybindsJSON struct {
SessionList apijson.Field
SessionNew apijson.Field
SessionShare apijson.Field
SessionUnshare apijson.Field
ThemeList apijson.Field
ToolDetails apijson.Field
raw string
+94 -84
View File
@@ -52,10 +52,10 @@ type EventListResponse struct {
// [EventListResponseEventPermissionUpdatedProperties],
// [EventListResponseEventFileEditedProperties],
// [EventListResponseEventInstallationUpdatedProperties],
// [EventListResponseEventStorageWriteProperties],
// [EventListResponseEventMessageUpdatedProperties],
// [EventListResponseEventMessageRemovedProperties],
// [EventListResponseEventMessagePartUpdatedProperties],
// [EventListResponseEventStorageWriteProperties],
// [EventListResponseEventSessionUpdatedProperties],
// [EventListResponseEventSessionDeletedProperties],
// [EventListResponseEventSessionIdleProperties],
@@ -96,11 +96,11 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) {
// [EventListResponseEventLspClientDiagnostics],
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
// [EventListResponseEventInstallationUpdated],
// [EventListResponseEventStorageWrite], [EventListResponseEventMessageUpdated],
// [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessagePartUpdated],
// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted],
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError],
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionIdle],
// [EventListResponseEventSessionError],
// [EventListResponseEventFileWatcherUpdated].
func (r EventListResponse) AsUnion() EventListResponseUnion {
return r.union
@@ -109,11 +109,11 @@ func (r EventListResponse) AsUnion() EventListResponseUnion {
// Union satisfied by [EventListResponseEventLspClientDiagnostics],
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
// [EventListResponseEventInstallationUpdated],
// [EventListResponseEventStorageWrite], [EventListResponseEventMessageUpdated],
// [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessagePartUpdated],
// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted],
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError] or
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionIdle],
// [EventListResponseEventSessionError] or
// [EventListResponseEventFileWatcherUpdated].
type EventListResponseUnion interface {
implementsEventListResponse()
@@ -143,11 +143,6 @@ func init() {
Type: reflect.TypeOf(EventListResponseEventInstallationUpdated{}),
DiscriminatorValue: "installation.updated",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventStorageWrite{}),
DiscriminatorValue: "storage.write",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventMessageUpdated{}),
@@ -163,6 +158,11 @@ func init() {
Type: reflect.TypeOf(EventListResponseEventMessagePartUpdated{}),
DiscriminatorValue: "message.part.updated",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventStorageWrite{}),
DiscriminatorValue: "storage.write",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventSessionUpdated{}),
@@ -462,68 +462,6 @@ func (r EventListResponseEventInstallationUpdatedType) IsKnown() bool {
return false
}
type EventListResponseEventStorageWrite struct {
Properties EventListResponseEventStorageWriteProperties `json:"properties,required"`
Type EventListResponseEventStorageWriteType `json:"type,required"`
JSON eventListResponseEventStorageWriteJSON `json:"-"`
}
// eventListResponseEventStorageWriteJSON contains the JSON metadata for the struct
// [EventListResponseEventStorageWrite]
type eventListResponseEventStorageWriteJSON struct {
Properties apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventStorageWrite) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventStorageWriteJSON) RawJSON() string {
return r.raw
}
func (r EventListResponseEventStorageWrite) implementsEventListResponse() {}
type EventListResponseEventStorageWriteProperties struct {
Key string `json:"key,required"`
Content interface{} `json:"content"`
JSON eventListResponseEventStorageWritePropertiesJSON `json:"-"`
}
// eventListResponseEventStorageWritePropertiesJSON contains the JSON metadata for
// the struct [EventListResponseEventStorageWriteProperties]
type eventListResponseEventStorageWritePropertiesJSON struct {
Key apijson.Field
Content apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventStorageWriteProperties) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventStorageWritePropertiesJSON) RawJSON() string {
return r.raw
}
type EventListResponseEventStorageWriteType string
const (
EventListResponseEventStorageWriteTypeStorageWrite EventListResponseEventStorageWriteType = "storage.write"
)
func (r EventListResponseEventStorageWriteType) IsKnown() bool {
switch r {
case EventListResponseEventStorageWriteTypeStorageWrite:
return true
}
return false
}
type EventListResponseEventMessageUpdated struct {
Properties EventListResponseEventMessageUpdatedProperties `json:"properties,required"`
Type EventListResponseEventMessageUpdatedType `json:"type,required"`
@@ -710,6 +648,68 @@ func (r EventListResponseEventMessagePartUpdatedType) IsKnown() bool {
return false
}
type EventListResponseEventStorageWrite struct {
Properties EventListResponseEventStorageWriteProperties `json:"properties,required"`
Type EventListResponseEventStorageWriteType `json:"type,required"`
JSON eventListResponseEventStorageWriteJSON `json:"-"`
}
// eventListResponseEventStorageWriteJSON contains the JSON metadata for the struct
// [EventListResponseEventStorageWrite]
type eventListResponseEventStorageWriteJSON struct {
Properties apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventStorageWrite) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventStorageWriteJSON) RawJSON() string {
return r.raw
}
func (r EventListResponseEventStorageWrite) implementsEventListResponse() {}
type EventListResponseEventStorageWriteProperties struct {
Key string `json:"key,required"`
Content interface{} `json:"content"`
JSON eventListResponseEventStorageWritePropertiesJSON `json:"-"`
}
// eventListResponseEventStorageWritePropertiesJSON contains the JSON metadata for
// the struct [EventListResponseEventStorageWriteProperties]
type eventListResponseEventStorageWritePropertiesJSON struct {
Key apijson.Field
Content apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventStorageWriteProperties) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventStorageWritePropertiesJSON) RawJSON() string {
return r.raw
}
type EventListResponseEventStorageWriteType string
const (
EventListResponseEventStorageWriteTypeStorageWrite EventListResponseEventStorageWriteType = "storage.write"
)
func (r EventListResponseEventStorageWriteType) IsKnown() bool {
switch r {
case EventListResponseEventStorageWriteTypeStorageWrite:
return true
}
return false
}
type EventListResponseEventSessionUpdated struct {
Properties EventListResponseEventSessionUpdatedProperties `json:"properties,required"`
Type EventListResponseEventSessionUpdatedType `json:"type,required"`
@@ -916,14 +916,16 @@ func (r eventListResponseEventSessionErrorJSON) RawJSON() string {
func (r EventListResponseEventSessionError) implementsEventListResponse() {}
type EventListResponseEventSessionErrorProperties struct {
Error EventListResponseEventSessionErrorPropertiesError `json:"error"`
JSON eventListResponseEventSessionErrorPropertiesJSON `json:"-"`
Error EventListResponseEventSessionErrorPropertiesError `json:"error"`
SessionID string `json:"sessionID"`
JSON eventListResponseEventSessionErrorPropertiesJSON `json:"-"`
}
// eventListResponseEventSessionErrorPropertiesJSON contains the JSON metadata for
// the struct [EventListResponseEventSessionErrorProperties]
type eventListResponseEventSessionErrorPropertiesJSON struct {
Error apijson.Field
SessionID apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@@ -972,13 +974,15 @@ func (r *EventListResponseEventSessionErrorPropertiesError) UnmarshalJSON(data [
//
// Possible runtime types of the union are [shared.ProviderAuthError],
// [shared.UnknownError],
// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError].
// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError],
// [shared.MessageAbortedError].
func (r EventListResponseEventSessionErrorPropertiesError) AsUnion() EventListResponseEventSessionErrorPropertiesErrorUnion {
return r.union
}
// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or
// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError].
// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError],
// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError] or
// [shared.MessageAbortedError].
type EventListResponseEventSessionErrorPropertiesErrorUnion interface {
ImplementsEventListResponseEventSessionErrorPropertiesError()
}
@@ -1002,6 +1006,11 @@ func init() {
Type: reflect.TypeOf(EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError{}),
DiscriminatorValue: "MessageOutputLengthError",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(shared.MessageAbortedError{}),
DiscriminatorValue: "MessageAbortedError",
},
)
}
@@ -1052,11 +1061,12 @@ const (
EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError EventListResponseEventSessionErrorPropertiesErrorName = "ProviderAuthError"
EventListResponseEventSessionErrorPropertiesErrorNameUnknownError EventListResponseEventSessionErrorPropertiesErrorName = "UnknownError"
EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError EventListResponseEventSessionErrorPropertiesErrorName = "MessageOutputLengthError"
EventListResponseEventSessionErrorPropertiesErrorNameMessageAbortedError EventListResponseEventSessionErrorPropertiesErrorName = "MessageAbortedError"
)
func (r EventListResponseEventSessionErrorPropertiesErrorName) IsKnown() bool {
switch r {
case EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError, EventListResponseEventSessionErrorPropertiesErrorNameUnknownError, EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError:
case EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError, EventListResponseEventSessionErrorPropertiesErrorNameUnknownError, EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError, EventListResponseEventSessionErrorPropertiesErrorNameMessageAbortedError:
return true
}
return false
@@ -1160,10 +1170,10 @@ const (
EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated"
EventListResponseTypeFileEdited EventListResponseType = "file.edited"
EventListResponseTypeInstallationUpdated EventListResponseType = "installation.updated"
EventListResponseTypeStorageWrite EventListResponseType = "storage.write"
EventListResponseTypeMessageUpdated EventListResponseType = "message.updated"
EventListResponseTypeMessageRemoved EventListResponseType = "message.removed"
EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated"
EventListResponseTypeStorageWrite EventListResponseType = "storage.write"
EventListResponseTypeSessionUpdated EventListResponseType = "session.updated"
EventListResponseTypeSessionDeleted EventListResponseType = "session.deleted"
EventListResponseTypeSessionIdle EventListResponseType = "session.idle"
@@ -1173,7 +1183,7 @@ const (
func (r EventListResponseType) IsKnown() bool {
switch r {
case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
return true
}
return false
+43 -44
View File
@@ -42,13 +42,55 @@ func (r *FileService) Read(ctx context.Context, query FileReadParams, opts ...op
}
// Get file status
func (r *FileService) Status(ctx context.Context, opts ...option.RequestOption) (res *[]FileStatusResponse, err error) {
func (r *FileService) Status(ctx context.Context, opts ...option.RequestOption) (res *[]File, err error) {
opts = append(r.Options[:], opts...)
path := "file/status"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
type File struct {
Added int64 `json:"added,required"`
Path string `json:"path,required"`
Removed int64 `json:"removed,required"`
Status FileStatus `json:"status,required"`
JSON fileJSON `json:"-"`
}
// fileJSON contains the JSON metadata for the struct [File]
type fileJSON struct {
Added apijson.Field
Path apijson.Field
Removed apijson.Field
Status apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *File) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r fileJSON) RawJSON() string {
return r.raw
}
type FileStatus string
const (
FileStatusAdded FileStatus = "added"
FileStatusDeleted FileStatus = "deleted"
FileStatusModified FileStatus = "modified"
)
func (r FileStatus) IsKnown() bool {
switch r {
case FileStatusAdded, FileStatusDeleted, FileStatusModified:
return true
}
return false
}
type FileReadResponse struct {
Content string `json:"content,required"`
Type FileReadResponseType `json:"type,required"`
@@ -87,49 +129,6 @@ func (r FileReadResponseType) IsKnown() bool {
return false
}
type FileStatusResponse struct {
Added int64 `json:"added,required"`
File string `json:"file,required"`
Removed int64 `json:"removed,required"`
Status FileStatusResponseStatus `json:"status,required"`
JSON fileStatusResponseJSON `json:"-"`
}
// fileStatusResponseJSON contains the JSON metadata for the struct
// [FileStatusResponse]
type fileStatusResponseJSON struct {
Added apijson.Field
File apijson.Field
Removed apijson.Field
Status apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *FileStatusResponse) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r fileStatusResponseJSON) RawJSON() string {
return r.raw
}
type FileStatusResponseStatus string
const (
FileStatusResponseStatusAdded FileStatusResponseStatus = "added"
FileStatusResponseStatusDeleted FileStatusResponseStatus = "deleted"
FileStatusResponseStatusModified FileStatusResponseStatus = "modified"
)
func (r FileStatusResponseStatus) IsKnown() bool {
switch r {
case FileStatusResponseStatusAdded, FileStatusResponseStatusDeleted, FileStatusResponseStatusModified:
return true
}
return false
}
type FileReadParams struct {
Path param.Field[string] `query:"path,required"`
}
+159 -50
View File
@@ -42,7 +42,7 @@ func (r *FindService) Files(ctx context.Context, query FindFilesParams, opts ...
}
// Find workspace symbols
func (r *FindService) Symbols(ctx context.Context, query FindSymbolsParams, opts ...option.RequestOption) (res *[]FindSymbolsResponse, err error) {
func (r *FindService) Symbols(ctx context.Context, query FindSymbolsParams, opts ...option.RequestOption) (res *[]Symbol, err error) {
opts = append(r.Options[:], opts...)
path := "find/symbol"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...)
@@ -50,27 +50,24 @@ func (r *FindService) Symbols(ctx context.Context, query FindSymbolsParams, opts
}
// Find text in files
func (r *FindService) Text(ctx context.Context, query FindTextParams, opts ...option.RequestOption) (res *[]FindTextResponse, err error) {
func (r *FindService) Text(ctx context.Context, query FindTextParams, opts ...option.RequestOption) (res *[]Match, err error) {
opts = append(r.Options[:], opts...)
path := "find"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...)
return
}
type FindSymbolsResponse = interface{}
type FindTextResponse struct {
AbsoluteOffset float64 `json:"absolute_offset,required"`
LineNumber float64 `json:"line_number,required"`
Lines FindTextResponseLines `json:"lines,required"`
Path FindTextResponsePath `json:"path,required"`
Submatches []FindTextResponseSubmatch `json:"submatches,required"`
JSON findTextResponseJSON `json:"-"`
type Match struct {
AbsoluteOffset float64 `json:"absolute_offset,required"`
LineNumber float64 `json:"line_number,required"`
Lines MatchLines `json:"lines,required"`
Path MatchPath `json:"path,required"`
Submatches []MatchSubmatch `json:"submatches,required"`
JSON matchJSON `json:"-"`
}
// findTextResponseJSON contains the JSON metadata for the struct
// [FindTextResponse]
type findTextResponseJSON struct {
// matchJSON contains the JSON metadata for the struct [Match]
type matchJSON struct {
AbsoluteOffset apijson.Field
LineNumber apijson.Field
Lines apijson.Field
@@ -80,66 +77,63 @@ type findTextResponseJSON struct {
ExtraFields map[string]apijson.Field
}
func (r *FindTextResponse) UnmarshalJSON(data []byte) (err error) {
func (r *Match) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r findTextResponseJSON) RawJSON() string {
func (r matchJSON) RawJSON() string {
return r.raw
}
type FindTextResponseLines struct {
Text string `json:"text,required"`
JSON findTextResponseLinesJSON `json:"-"`
type MatchLines struct {
Text string `json:"text,required"`
JSON matchLinesJSON `json:"-"`
}
// findTextResponseLinesJSON contains the JSON metadata for the struct
// [FindTextResponseLines]
type findTextResponseLinesJSON struct {
// matchLinesJSON contains the JSON metadata for the struct [MatchLines]
type matchLinesJSON struct {
Text apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *FindTextResponseLines) UnmarshalJSON(data []byte) (err error) {
func (r *MatchLines) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r findTextResponseLinesJSON) RawJSON() string {
func (r matchLinesJSON) RawJSON() string {
return r.raw
}
type FindTextResponsePath struct {
Text string `json:"text,required"`
JSON findTextResponsePathJSON `json:"-"`
type MatchPath struct {
Text string `json:"text,required"`
JSON matchPathJSON `json:"-"`
}
// findTextResponsePathJSON contains the JSON metadata for the struct
// [FindTextResponsePath]
type findTextResponsePathJSON struct {
// matchPathJSON contains the JSON metadata for the struct [MatchPath]
type matchPathJSON struct {
Text apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *FindTextResponsePath) UnmarshalJSON(data []byte) (err error) {
func (r *MatchPath) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r findTextResponsePathJSON) RawJSON() string {
func (r matchPathJSON) RawJSON() string {
return r.raw
}
type FindTextResponseSubmatch struct {
End float64 `json:"end,required"`
Match FindTextResponseSubmatchesMatch `json:"match,required"`
Start float64 `json:"start,required"`
JSON findTextResponseSubmatchJSON `json:"-"`
type MatchSubmatch struct {
End float64 `json:"end,required"`
Match MatchSubmatchesMatch `json:"match,required"`
Start float64 `json:"start,required"`
JSON matchSubmatchJSON `json:"-"`
}
// findTextResponseSubmatchJSON contains the JSON metadata for the struct
// [FindTextResponseSubmatch]
type findTextResponseSubmatchJSON struct {
// matchSubmatchJSON contains the JSON metadata for the struct [MatchSubmatch]
type matchSubmatchJSON struct {
End apijson.Field
Match apijson.Field
Start apijson.Field
@@ -147,32 +141,147 @@ type findTextResponseSubmatchJSON struct {
ExtraFields map[string]apijson.Field
}
func (r *FindTextResponseSubmatch) UnmarshalJSON(data []byte) (err error) {
func (r *MatchSubmatch) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r findTextResponseSubmatchJSON) RawJSON() string {
func (r matchSubmatchJSON) RawJSON() string {
return r.raw
}
type FindTextResponseSubmatchesMatch struct {
Text string `json:"text,required"`
JSON findTextResponseSubmatchesMatchJSON `json:"-"`
type MatchSubmatchesMatch struct {
Text string `json:"text,required"`
JSON matchSubmatchesMatchJSON `json:"-"`
}
// findTextResponseSubmatchesMatchJSON contains the JSON metadata for the struct
// [FindTextResponseSubmatchesMatch]
type findTextResponseSubmatchesMatchJSON struct {
// matchSubmatchesMatchJSON contains the JSON metadata for the struct
// [MatchSubmatchesMatch]
type matchSubmatchesMatchJSON struct {
Text apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *FindTextResponseSubmatchesMatch) UnmarshalJSON(data []byte) (err error) {
func (r *MatchSubmatchesMatch) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r findTextResponseSubmatchesMatchJSON) RawJSON() string {
func (r matchSubmatchesMatchJSON) RawJSON() string {
return r.raw
}
type Symbol struct {
Kind float64 `json:"kind,required"`
Location SymbolLocation `json:"location,required"`
Name string `json:"name,required"`
JSON symbolJSON `json:"-"`
}
// symbolJSON contains the JSON metadata for the struct [Symbol]
type symbolJSON struct {
Kind apijson.Field
Location apijson.Field
Name apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Symbol) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r symbolJSON) RawJSON() string {
return r.raw
}
type SymbolLocation struct {
Range SymbolLocationRange `json:"range,required"`
Uri string `json:"uri,required"`
JSON symbolLocationJSON `json:"-"`
}
// symbolLocationJSON contains the JSON metadata for the struct [SymbolLocation]
type symbolLocationJSON struct {
Range apijson.Field
Uri apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *SymbolLocation) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r symbolLocationJSON) RawJSON() string {
return r.raw
}
type SymbolLocationRange struct {
End SymbolLocationRangeEnd `json:"end,required"`
Start SymbolLocationRangeStart `json:"start,required"`
JSON symbolLocationRangeJSON `json:"-"`
}
// symbolLocationRangeJSON contains the JSON metadata for the struct
// [SymbolLocationRange]
type symbolLocationRangeJSON struct {
End apijson.Field
Start apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *SymbolLocationRange) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r symbolLocationRangeJSON) RawJSON() string {
return r.raw
}
type SymbolLocationRangeEnd struct {
Character float64 `json:"character,required"`
Line float64 `json:"line,required"`
JSON symbolLocationRangeEndJSON `json:"-"`
}
// symbolLocationRangeEndJSON contains the JSON metadata for the struct
// [SymbolLocationRangeEnd]
type symbolLocationRangeEndJSON struct {
Character apijson.Field
Line apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *SymbolLocationRangeEnd) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r symbolLocationRangeEndJSON) RawJSON() string {
return r.raw
}
type SymbolLocationRangeStart struct {
Character float64 `json:"character,required"`
Line float64 `json:"line,required"`
JSON symbolLocationRangeStartJSON `json:"-"`
}
// symbolLocationRangeStartJSON contains the JSON metadata for the struct
// [SymbolLocationRangeStart]
type symbolLocationRangeStartJSON struct {
Character apijson.Field
Line apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *SymbolLocationRangeStart) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r symbolLocationRangeStartJSON) RawJSON() string {
return r.raw
}
@@ -29,7 +29,7 @@ func NewDecoder(res *http.Response) Decoder {
decoder = t(res.Body)
} else {
scn := bufio.NewScanner(res.Body)
scn.Buffer(nil, bufio.MaxScanTokenSize<<4)
scn.Buffer(nil, bufio.MaxScanTokenSize<<9)
decoder = &eventStreamDecoder{rc: res.Body, scn: scn}
}
return decoder
+5 -2
View File
@@ -60,5 +60,8 @@
}
],
"release-type": "go",
"extra-files": ["internal/version.go", "README.md"]
}
"extra-files": [
"internal/version.go",
"README.md"
]
}
+4 -1
View File
@@ -5,4 +5,7 @@ set -e
cd "$(dirname "$0")/.."
echo "==> Running Go build"
go build ./...
go build .
echo "==> Checking tests compile"
go test -run=^$ .
+20 -13
View File
@@ -340,13 +340,14 @@ func (r *AssistantMessageError) UnmarshalJSON(data []byte) (err error) {
// the specific types for more type safety.
//
// Possible runtime types of the union are [shared.ProviderAuthError],
// [shared.UnknownError], [AssistantMessageErrorMessageOutputLengthError].
// [shared.UnknownError], [AssistantMessageErrorMessageOutputLengthError],
// [shared.MessageAbortedError].
func (r AssistantMessageError) AsUnion() AssistantMessageErrorUnion {
return r.union
}
// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or
// [AssistantMessageErrorMessageOutputLengthError].
// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError],
// [AssistantMessageErrorMessageOutputLengthError] or [shared.MessageAbortedError].
type AssistantMessageErrorUnion interface {
ImplementsAssistantMessageError()
}
@@ -370,6 +371,11 @@ func init() {
Type: reflect.TypeOf(AssistantMessageErrorMessageOutputLengthError{}),
DiscriminatorValue: "MessageOutputLengthError",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(shared.MessageAbortedError{}),
DiscriminatorValue: "MessageAbortedError",
},
)
}
@@ -418,11 +424,12 @@ const (
AssistantMessageErrorNameProviderAuthError AssistantMessageErrorName = "ProviderAuthError"
AssistantMessageErrorNameUnknownError AssistantMessageErrorName = "UnknownError"
AssistantMessageErrorNameMessageOutputLengthError AssistantMessageErrorName = "MessageOutputLengthError"
AssistantMessageErrorNameMessageAbortedError AssistantMessageErrorName = "MessageAbortedError"
)
func (r AssistantMessageErrorName) IsKnown() bool {
switch r {
case AssistantMessageErrorNameProviderAuthError, AssistantMessageErrorNameUnknownError, AssistantMessageErrorNameMessageOutputLengthError:
case AssistantMessageErrorNameProviderAuthError, AssistantMessageErrorNameUnknownError, AssistantMessageErrorNameMessageOutputLengthError, AssistantMessageErrorNameMessageAbortedError:
return true
}
return false
@@ -889,7 +896,7 @@ func (r ToolPart) implementsAssistantMessagePart() {}
type ToolPartState struct {
Status ToolPartStateStatus `json:"status,required"`
Error string `json:"error"`
// This field can have the runtime type of [interface{}].
// This field can have the runtime type of [interface{}], [map[string]interface{}].
Input interface{} `json:"input"`
// This field can have the runtime type of [map[string]interface{}].
Metadata interface{} `json:"metadata"`
@@ -1002,24 +1009,24 @@ func (r ToolPartType) IsKnown() bool {
}
type ToolStateCompleted struct {
Input map[string]interface{} `json:"input,required"`
Metadata map[string]interface{} `json:"metadata,required"`
Output string `json:"output,required"`
Status ToolStateCompletedStatus `json:"status,required"`
Time ToolStateCompletedTime `json:"time,required"`
Title string `json:"title,required"`
Input interface{} `json:"input"`
JSON toolStateCompletedJSON `json:"-"`
}
// toolStateCompletedJSON contains the JSON metadata for the struct
// [ToolStateCompleted]
type toolStateCompletedJSON struct {
Input apijson.Field
Metadata apijson.Field
Output apijson.Field
Status apijson.Field
Time apijson.Field
Title apijson.Field
Input apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@@ -1072,19 +1079,19 @@ func (r toolStateCompletedTimeJSON) RawJSON() string {
}
type ToolStateError struct {
Error string `json:"error,required"`
Status ToolStateErrorStatus `json:"status,required"`
Time ToolStateErrorTime `json:"time,required"`
Input interface{} `json:"input"`
JSON toolStateErrorJSON `json:"-"`
Error string `json:"error,required"`
Input map[string]interface{} `json:"input,required"`
Status ToolStateErrorStatus `json:"status,required"`
Time ToolStateErrorTime `json:"time,required"`
JSON toolStateErrorJSON `json:"-"`
}
// toolStateErrorJSON contains the JSON metadata for the struct [ToolStateError]
type toolStateErrorJSON struct {
Error apijson.Field
Input apijson.Field
Status apijson.Field
Time apijson.Field
Input apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
+41
View File
@@ -6,6 +6,47 @@ import (
"github.com/sst/opencode-sdk-go/internal/apijson"
)
type MessageAbortedError struct {
Data interface{} `json:"data,required"`
Name MessageAbortedErrorName `json:"name,required"`
JSON messageAbortedErrorJSON `json:"-"`
}
// messageAbortedErrorJSON contains the JSON metadata for the struct
// [MessageAbortedError]
type messageAbortedErrorJSON struct {
Data apijson.Field
Name apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *MessageAbortedError) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r messageAbortedErrorJSON) RawJSON() string {
return r.raw
}
func (r MessageAbortedError) ImplementsEventListResponseEventSessionErrorPropertiesError() {}
func (r MessageAbortedError) ImplementsAssistantMessageError() {}
type MessageAbortedErrorName string
const (
MessageAbortedErrorNameMessageAbortedError MessageAbortedErrorName = "MessageAbortedError"
)
func (r MessageAbortedErrorName) IsKnown() bool {
switch r {
case MessageAbortedErrorNameMessageAbortedError:
return true
}
return false
}
type ProviderAuthError struct {
Data ProviderAuthErrorData `json:"data,required"`
Name ProviderAuthErrorName `json:"name,required"`
+21
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",
@@ -78,4 +84,19 @@ export default defineConfig({
],
}),
],
redirects: {
"/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
+44 -68
View File
@@ -1,24 +1,13 @@
import {
For,
Show,
Match,
Switch,
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 { IconOpenAI, IconGemini, IconOpencode, IconAnthropic } from "./icons/custom"
import { IconSparkles, IconArrowDown } from "./icons"
import { IconArrowDown } from "./icons"
import { IconOpencode } from "./icons/custom"
import styles from "./share.module.css"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Message } from "opencode/session/message"
import type { Session } from "opencode/session/index"
import { Part } from "./share/part"
import { Part, ProviderIcon } from "./share/part"
type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting"
@@ -46,23 +35,6 @@ function getStatusText(status: [Status, string?]): string {
}
}
function ProviderIcon(props: { provider: string; size?: number }) {
const size = props.size || 16
return (
<Switch fallback={<IconSparkles width={size} height={size} />}>
<Match when={props.provider === "openai"}>
<IconOpenAI width={size} height={size} />
</Match>
<Match when={props.provider === "anthropic"}>
<IconAnthropic width={size} height={size} />
</Match>
<Match when={props.provider === "gemini"}>
<IconGemini width={size} height={size} />
</Match>
</Switch>
)
}
export default function Share(props: {
id: string
api: string
@@ -296,7 +268,6 @@ export default function Share(props: {
}
}
}
console.log(result.messages)
return result
})
@@ -319,7 +290,7 @@ export default function Share(props: {
{([provider, model]) => (
<li data-slot="item">
<div data-slot="icon" title={provider}>
<ProviderIcon provider={provider} />
<ProviderIcon model={model} />
</div>
<span data-slot="model">{model}</span>
</li>
@@ -346,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;
}
}
}
@@ -49,3 +49,12 @@ export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
// https://icones.js.org/collection/ri?s=meta&icon=ri:meta-fill
export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M16.92 4.5c-1.851 0-3.298 1.394-4.608 3.165C10.512 5.373 9.007 4.5 7.206 4.5C3.534 4.5.72 9.28.72 14.338c0 3.165 1.531 5.162 4.096 5.162c1.846 0 3.174-.87 5.535-4.997c0 0 .984-1.737 1.66-2.934q.356.574.75 1.238l1.107 1.862c2.156 3.608 3.358 4.831 5.534 4.831c2.5 0 3.89-2.024 3.89-5.255c0-5.297-2.877-9.745-6.372-9.745m-8.37 8.886c-1.913 3-2.575 3.673-3.64 3.673c-1.097 0-1.749-.963-1.749-2.68c0-3.672 1.831-7.427 4.014-7.427c1.182 0 2.17.682 3.683 2.848c-1.437 2.204-2.307 3.586-2.307 3.586m7.224-.377L14.45 10.8a45 45 0 0 0-1.032-1.608c1.193-1.841 2.176-2.759 3.347-2.759c2.43 0 4.375 3.58 4.375 7.976c0 1.676-.549 2.649-1.686 2.649c-1.09 0-1.61-.72-3.68-4.05" />
</svg>
)
}
@@ -57,6 +57,7 @@
display: flex;
gap: 0.5rem 0.875rem;
flex-wrap: wrap;
max-width: var(--lg-tool-width);
[data-slot="item"] {
display: flex;
@@ -13,6 +13,7 @@
pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important;
line-height: 1.6;
font-size: 0.75rem;
white-space: pre-wrap;
@@ -1,7 +1,7 @@
import { type JSX, splitProps, createResource, Suspense } from "solid-js"
import { codeToHtml } from "shiki"
import style from "./content-code.module.css"
import { createResource, Suspense } from "solid-js"
import { transformerNotationDiff } from "@shikijs/transformers"
import style from "./content-code.module.css"
interface Props {
code: string
@@ -0,0 +1,66 @@
.root {
background-color: var(--sl-color-bg-surface);
padding: 0.5rem calc(0.5rem + 3px);
border-radius: 0.25rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
align-self: flex-start;
max-width: var(--md-tool-width);
[data-section="content"] {
pre {
margin-bottom: 0.5rem;
line-height: 1.5;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
&:last-child {
margin-bottom: 0;
}
span {
margin-right: 0.25rem;
&:last-child {
margin-right: 0;
}
}
span[data-color="red"] {
color: var(--sl-color-red);
}
span[data-color="dimmed"] {
color: var(--sl-color-text-dimmed);
}
span[data-marker="label"] {
text-transform: uppercase;
letter-spacing: -0.5px;
}
span[data-separator] {
margin-right: 0.375rem;
}
}
}
&[data-expanded="true"] {
[data-section="content"] {
display: block;
}
}
&[data-expanded="false"] {
[data-section="content"] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
overflow: hidden;
}
}
button {
flex: 0 0 auto;
padding: 2px 0;
font-size: 0.75rem;
}
}
@@ -0,0 +1,31 @@
import style from "./content-error.module.css"
import { type JSX, createSignal } from "solid-js"
import { createOverflow } from "./common"
interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
expand?: boolean
}
export function ContentError(props: Props) {
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
return (
<div
class={style.root}
data-expanded={expanded() || props.expand === true ? true : undefined}
>
<div data-section="content" ref={overflow.ref}>
{props.children}
</div>
{((!props.expand && overflow.status) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
+88 -19
View File
@@ -1,31 +1,42 @@
import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js"
import map from "lang-map"
import { DateTime } from "luxon"
import {
For,
Show,
Match,
Switch,
type JSX,
createMemo,
createSignal,
type ParentProps
} from "solid-js"
import {
IconCheckCircle,
IconChevronDown,
IconChevronRight,
IconHashtag,
IconSparkles,
IconGlobeAlt,
IconDocument,
IconQueueList,
IconUserCircle,
IconCommandLine,
IconCheckCircle,
IconChevronDown,
IconChevronRight,
IconDocumentPlus,
IconPencilSquare,
IconRectangleStack,
IconMagnifyingGlass,
IconDocumentMagnifyingGlass,
} from "../icons"
import styles from "./part.module.css"
import type { MessageV2 } from "opencode/session/message-v2"
import { ContentText } from "./content-text"
import { ContentMarkdown } from "./content-markdown"
import { DateTime } from "luxon"
import CodeBlock from "../CodeBlock"
import map from "lang-map"
import type { Diagnostic } from "vscode-languageserver-types"
import { IconMeta, IconOpenAI, IconGemini, IconAnthropic } from "../icons/custom"
import { ContentCode } from "./content-code"
import { ContentDiff } from "./content-diff"
import { ContentText } from "./content-text"
import { ContentError } from "./content-error"
import { ContentMarkdown } from "./content-markdown"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Diagnostic } from "vscode-languageserver-types"
import styles from "./part.module.css"
export interface PartProps {
index: number
@@ -65,6 +76,15 @@ export function Part(props: PartProps) {
}}
>
<Switch>
<Match when={props.message.role === "user" && props.part.type === "text"}>
<IconUserCircle width={18} height={18} />
</Match>
<Match when={props.message.role === "user" && props.part.type === "file"}>
<IconDocument width={18} height={18} />
</Match>
<Match when={props.part.type === "step-start" && props.message.role === "assistant" && props.message.modelID}>
{model => <ProviderIcon model={model()} size={18} />}
</Match>
<Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
<IconQueueList width={18} height={18} />
</Match>
@@ -112,7 +132,8 @@ export function Part(props: PartProps) {
<div data-component="content">
{props.message.role === "user" && props.part.type === "text" && (
<>
<ContentText text={props.part.text} expand={props.last} /> <Spacer />
<ContentText text={props.part.text} expand={props.last} />
<Spacer />
</>
)}
{props.message.role === "assistant" && props.part.type === "text" && (
@@ -130,12 +151,28 @@ export function Part(props: PartProps) {
<Spacer />
</>
)}
{props.message.role === "user" && props.part.type === "file" && (
<div data-component="tool-title">
<span data-slot="name">Read</span>
<span data-slot="target" title={props.part.filename}>
{props.part.filename}
</span>
</div>
)}
{props.part.type === "step-start" && props.message.role === "assistant" && (
<div data-component="step-start">
<div data-slot="provider">{props.message.providerID}</div>
<div data-slot="model">{props.message.modelID}</div>
</div>
)}
{props.part.type === "tool" &&
props.part.state.status === "error" && (
<div data-component="tool">
<ContentError>
{formatErrorString(props.part.state.error)}
</ContentError>
</div>
)}
{props.part.type === "tool" &&
props.part.state.status === "completed" &&
props.message.role === "assistant" && (
@@ -420,11 +457,11 @@ export function WebFetchTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.output}>
<ResultsButton>
<CodeBlock lang={props.state.input.format || "text"} code={props.state.output} />
<ContentCode lang={props.state.input.format || "text"} code={props.state.output} />
</ResultsButton>
</Match>
</Switch>
@@ -447,7 +484,7 @@ export function ReadTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={typeof props.state.metadata?.preview === "string"}>
<ResultsButton showCopy="Show preview" hideCopy="Hide preview">
@@ -483,7 +520,7 @@ export function WriteTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.output)}</div>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.input?.content}>
<ResultsButton showCopy="Show contents" hideCopy="Hide contents">
@@ -511,7 +548,7 @@ export function EditTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<div data-component="error">{formatErrorString(props.state.metadata?.message || "")}</div>
<ContentError>{formatErrorString(props.state.metadata?.message || "")}</ContentError>
</Match>
<Match when={props.state.metadata?.diff}>
<div data-component="diff">
@@ -662,3 +699,35 @@ function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
return entries
}
function getProvider(model: string) {
const lowerModel = model.toLowerCase()
if (/claude|anthropic/.test(lowerModel)) return "anthropic"
if (/gpt|o[1-4]|codex|openai/.test(lowerModel)) return "openai"
if (/gemini|palm|bard|google/.test(lowerModel)) return "gemini"
if (/llama|meta/.test(lowerModel)) return "meta"
return "any"
}
export function ProviderIcon(props: { model: string; size?: number }) {
const provider = getProvider(props.model)
const size = props.size || 16
return (
<Switch fallback={<IconSparkles width={size} height={size} />}>
<Match when={provider === "openai"}>
<IconOpenAI width={size} height={size} />
</Match>
<Match when={provider === "anthropic"}>
<IconAnthropic width={size} height={size} />
</Match>
<Match when={provider === "gemini"}>
<IconGemini width={size} height={size} />
</Match>
<Match when={provider === "meta"}>
<IconMeta width={size} height={size} />
</Match>
</Switch>
)
}
@@ -127,3 +127,5 @@ 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 |
@@ -63,6 +63,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.
@@ -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.
+20 -4
View File
@@ -21,10 +21,26 @@ import { Tabs, TabItem } from "@astrojs/starlight/components"
## Install
<Tabs>
<TabItem label="npm">```bash npm install -g opencode-ai ```</TabItem>
<TabItem label="Bun">```bash bun install -g opencode-ai ```</TabItem>
<TabItem label="pnpm">```bash pnpm install -g opencode-ai ```</TabItem>
<TabItem label="Yarn">```bash yarn global add opencode-ai ```</TabItem>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
</Tabs>
You can also install the opencode binary through the following.
+4 -13
View File
@@ -81,23 +81,14 @@ If you have both global and project-specific rules, opencode will combine them t
## Custom Instructions
You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`:
You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/config.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.
Example:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"instructions": [".cursor/rules/*.md"]
}
```
You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files.
For example, to reuse your existing Cursor rules:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"instructions": [".cursor/rules/*.md"]
"instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"]
}
```
@@ -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.
@@ -9,6 +9,18 @@ By default, opencode uses our own `opencode` theme.
---
## Terminal requirements
For themes to display correctly with their full color palette, your terminal must support **truecolor** (24-bit color). Most modern terminals support this by default, but you may need to enable it:
- **Check support**: Run `echo $COLORTERM` - it should output `truecolor` or `24bit`
- **Enable truecolor**: Set the environment variable `COLORTERM=truecolor` in your shell profile
- **Terminal compatibility**: Ensure your terminal emulator supports 24-bit color (most modern terminals like iTerm2, Alacritty, Kitty, Windows Terminal, and recent versions of GNOME Terminal do)
Without truecolor support, themes may appear with reduced color accuracy or fall back to the nearest 256-color approximation.
---
## Built-in themes
opencode comes with several built-in themes.
@@ -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
+14 -4
View File
@@ -23,9 +23,8 @@ const models: Set<string> = new Set();
const version = data.info.version ? `v${data.info.version}` : "v0.0.1";
Object.values(data.messages).forEach((d) => {
const assistant = d.metadata?.assistant;
if (assistant) {
models.add(assistant.modelID);
if (d.role === "assistant" && d.modelID) {
models.add(d.modelID);
}
});
@@ -39,8 +38,19 @@ const encodedTitle = encodeURIComponent(
)
);
const ogImage = `${config.socialCard}/opencode-share/${encodedTitle}.png?model=${Array.from(models).join(",")}&version=${version}&id=${id}`;
const modelsArray = Array.from(models);
let modelParam;
if (modelsArray.length === 1) {
modelParam = modelsArray[0];
}
else if (modelsArray.length === 2) {
modelParam = encodeURIComponent(`${modelsArray[0]} & ${modelsArray[1]}`);
}
else {
modelParam = encodeURIComponent(`${modelsArray[0]} & ${modelsArray.length - 1} others`);
}
const ogImage = `${config.socialCard}/opencode-share/${encodedTitle}.png?model=${modelParam}&version=${version}&id=${id}`;
---
<StarlightPage
hasSidebar={false}
+8
View File
@@ -34,6 +34,7 @@ resources:
models:
unknownError: UnknownError
providerAuthError: ProviderAuthError
messageAbortedError: MessageAbortedError
event:
methods:
@@ -47,17 +48,24 @@ resources:
app:
models:
app: App
logLevel: LogLevel
methods:
get: get /app
init: post /app/init
log: post /log
find:
models:
match: Match
symbol: Symbol
methods:
text: get /find
files: get /find/file
symbols: get /find/symbol
file:
models:
file: File
methods:
read: get /file
status: get /file/status