mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49de703ba1 | |||
| 22988894c8 | |||
| 34b1754f25 | |||
| 54fe3504ba | |||
| d2c862e32d | |||
| afc53afb35 | |||
| b56e49c5dc | |||
| 8b2a909e1f | |||
| e9c954d45e | |||
| 6f449d13af | |||
| 6e375bef0d | |||
| 67106a6967 | |||
| b5d690620d | |||
| 9db3ce1d0b | |||
| 1cc55b68ef | |||
| 469f667774 | |||
| 6603d9a9f0 | |||
| 5dc1920a4c | |||
| d3e5f3f3a8 | |||
| ce4cb820f7 | |||
| ba5be6b625 | |||
| f95c3f4177 | |||
| d2b1307bff | |||
| b40ba32adc | |||
| ce0cebb7d7 | |||
| f478f89a68 | |||
| 85d95f0f2b | |||
| 1515efc77c | |||
| 6d393759e1 | |||
| a1701678cd | |||
| c411a26d6f | |||
| 85dbfeb314 | |||
| 085c0e4e2b | |||
| 8404a97c3e | |||
| 0ee3b1ede2 | |||
| a826936702 | |||
| fd4a5d5a63 | |||
| 69cf1d7b7e | |||
| 8e0a1d1167 | |||
| f22021187d | |||
| febecc348a | |||
| c5ccfc3e94 |
@@ -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>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cmd } from "../cmd"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
@@ -12,6 +13,7 @@ export const DebugCommand = cmd({
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Log } from "../../../util/log"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -31,3 +32,15 @@ export const SymbolsCommand = cmd({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = cmd({
|
||||
command: "document-symbols <uri>",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {},
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { Mode } from "../../session/mode"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
@@ -52,6 +53,10 @@ export const RunCommand = cmd({
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = args.message.join(" ")
|
||||
@@ -139,10 +144,17 @@ export const RunCommand = cmd({
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
...(mode.model
|
||||
? mode.model
|
||||
: {
|
||||
providerID,
|
||||
modelID,
|
||||
}),
|
||||
mode: mode.name,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
import { Mode } from "../../session/mode"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
@@ -30,6 +31,10 @@ export const TuiCommand = cmd({
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
@@ -77,6 +82,7 @@ export const TuiCommand = cmd({
|
||||
...cmd,
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
...(args.mode ? ["--mode", args.mode] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
@@ -87,6 +93,7 @@ export const TuiCommand = cmd({
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
OPENCODE_MODES: JSON.stringify(await Mode.list()),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
|
||||
@@ -55,10 +55,22 @@ export namespace Config {
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Mode = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ModeConfig",
|
||||
})
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
switch_mode: z.string().optional().default("tab").describe("Switch mode"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
@@ -99,6 +111,7 @@ export namespace Config {
|
||||
.openapi({
|
||||
ref: "KeybindsConfig",
|
||||
})
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
@@ -108,6 +121,13 @@ export namespace Config {
|
||||
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
|
||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
mode: z
|
||||
.object({
|
||||
build: Mode.optional(),
|
||||
plan: Mode.optional(),
|
||||
})
|
||||
.catchall(Mode)
|
||||
.optional(),
|
||||
log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
|
||||
provider: z
|
||||
.record(
|
||||
@@ -175,19 +195,40 @@ export namespace Config {
|
||||
return result
|
||||
})
|
||||
|
||||
async function load(path: string) {
|
||||
const data = await Bun.file(path)
|
||||
.json()
|
||||
async function load(configPath: string) {
|
||||
let text = await Bun.file(configPath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return {}
|
||||
throw new JsonError({ path }, { cause: err })
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: configPath }, { cause: err })
|
||||
})
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(configPath)
|
||||
for (const match of fileMatches) {
|
||||
const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = await Bun.file(resolvedPath).text()
|
||||
text = text.replace(match, JSON.stringify(fileContent))
|
||||
}
|
||||
}
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new JsonError({ path: configPath }, { cause: err as Error })
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) return parsed.data
|
||||
throw new InvalidError({ path, issues: parsed.error.issues })
|
||||
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -184,9 +184,6 @@ export namespace LSPClient {
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) {
|
||||
await input.server.onInitialized(result)
|
||||
}
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
|
||||
@@ -4,27 +4,33 @@ import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Range = z
|
||||
.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Range",
|
||||
})
|
||||
export type Range = z.infer<typeof Range>
|
||||
|
||||
export const Symbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
kind: z.number(),
|
||||
location: z.object({
|
||||
uri: z.string(),
|
||||
range: z.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
}),
|
||||
range: Range,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
@@ -32,38 +38,25 @@ export namespace LSP {
|
||||
})
|
||||
export type Symbol = z.infer<typeof Symbol>
|
||||
|
||||
export const DocumentSymbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
detail: z.string().optional(),
|
||||
kind: z.number(),
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
.openapi({
|
||||
ref: "DocumentSymbol",
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
const state = App.state(
|
||||
"lsp",
|
||||
async (app) => {
|
||||
log.info("initializing")
|
||||
async () => {
|
||||
const clients: LSPClient.Info[] = []
|
||||
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
const roots = await server.roots(app)
|
||||
|
||||
for (const root of roots) {
|
||||
if (!Filesystem.overlaps(app.path.cwd, root)) continue
|
||||
log.info("", {
|
||||
root,
|
||||
serverID: server.id,
|
||||
})
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) break
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) break
|
||||
clients.push(client)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("initialized")
|
||||
return {
|
||||
broken: new Set<string>(),
|
||||
clients,
|
||||
}
|
||||
},
|
||||
@@ -78,13 +71,43 @@ export namespace LSP {
|
||||
return state()
|
||||
}
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
const extension = path.parse(file).ext
|
||||
const result: LSPClient.Info[] = []
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
if (!server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file, App.info())
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) continue
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) continue
|
||||
s.clients.push(client)
|
||||
result.push(client)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||
const extension = path.parse(input).ext
|
||||
const matches = Object.values(LSPServer)
|
||||
.filter((x) => x.extensions.includes(extension))
|
||||
.map((x) => x.id)
|
||||
const clients = await getClients(input)
|
||||
await run(async (client) => {
|
||||
if (!matches.includes(client.serverID)) return
|
||||
if (!clients.includes(client)) return
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
@@ -117,17 +140,72 @@ export namespace LSP {
|
||||
})
|
||||
}
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
Namespace = 3,
|
||||
Package = 4,
|
||||
Class = 5,
|
||||
Method = 6,
|
||||
Property = 7,
|
||||
Field = 8,
|
||||
Constructor = 9,
|
||||
Enum = 10,
|
||||
Interface = 11,
|
||||
Function = 12,
|
||||
Variable = 13,
|
||||
Constant = 14,
|
||||
String = 15,
|
||||
Number = 16,
|
||||
Boolean = 17,
|
||||
Array = 18,
|
||||
Object = 19,
|
||||
Key = 20,
|
||||
Null = 21,
|
||||
EnumMember = 22,
|
||||
Struct = 23,
|
||||
Event = 24,
|
||||
Operator = 25,
|
||||
TypeParameter = 26,
|
||||
}
|
||||
|
||||
const kinds = [
|
||||
SymbolKind.Class,
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Interface,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Constant,
|
||||
SymbolKind.Struct,
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return run((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
})
|
||||
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat() as LSP.Symbol[])
|
||||
}
|
||||
|
||||
export async function documentSymbol(uri: string) {
|
||||
return run((client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/documentSymbol", {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
|
||||
.then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await state().then((x) => x.clients)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
|
||||
@@ -6,10 +6,7 @@ import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import { unique } from "remeda"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import type { LSPClient } from "./client"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -17,19 +14,21 @@ export namespace LSPServer {
|
||||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
initialization?: Record<string, any>
|
||||
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
|
||||
}
|
||||
|
||||
type RootsFunction = (app: App.Info) => Promise<string[]>
|
||||
type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
|
||||
|
||||
const SimpleRoots = (patterns: string[]): RootsFunction => {
|
||||
return async (app) => {
|
||||
const files = await Ripgrep.files({
|
||||
glob: patterns.map(p => `**/${p}`),
|
||||
cwd: app.path.root,
|
||||
const NearestRoot = (patterns: string[]): RootFunction => {
|
||||
return async (file, app) => {
|
||||
const files = Filesystem.up({
|
||||
targets: patterns,
|
||||
start: path.dirname(file),
|
||||
stop: app.path.root,
|
||||
})
|
||||
const dirs = files.map((file) => path.dirname(file))
|
||||
return unique(dirs).map((dir) => path.join(app.path.root, dir))
|
||||
const first = await files.next()
|
||||
await files.return()
|
||||
if (!first.value) return app.path.root
|
||||
return path.dirname(first.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +36,13 @@ export namespace LSPServer {
|
||||
id: string
|
||||
extensions: string[]
|
||||
global?: boolean
|
||||
roots: (app: App.Info) => Promise<string[]>
|
||||
root: RootFunction
|
||||
spawn(app: App.Info, root: string): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const Typescript: Info = {
|
||||
id: "typescript",
|
||||
roots: async (app) => [app.path.root],
|
||||
root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(app, root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
|
||||
@@ -62,30 +61,17 @@ export namespace LSPServer {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
// tsserver sucks and won't start processing codebase until you open a file
|
||||
onInitialized: async (lsp) => {
|
||||
const [hint] = await Ripgrep.files({
|
||||
cwd: lsp.root,
|
||||
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
|
||||
limit: 1,
|
||||
})
|
||||
const wait = new Promise<void>(async (resolve) => {
|
||||
const notif = lsp.connection.onNotification("$/progress", (params) => {
|
||||
if (params.value.kind !== "end") return
|
||||
notif.dispose()
|
||||
resolve()
|
||||
})
|
||||
await lsp.notify.open({ path: path.join(lsp.root, hint) })
|
||||
})
|
||||
await withTimeout(wait, 5_000)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
roots: SimpleRoots(["go.mod", "go.sum"]),
|
||||
root: async (file, app) => {
|
||||
const work = await NearestRoot(["go.work"])(file, app)
|
||||
if (work) return work
|
||||
return NearestRoot(["go.mod", "go.sum"])(file, app)
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
@@ -121,7 +107,7 @@ export namespace LSPServer {
|
||||
|
||||
export const RubyLsp: Info = {
|
||||
id: "ruby-lsp",
|
||||
roots: SimpleRoots(["Gemfile"]),
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("ruby-lsp", {
|
||||
@@ -162,14 +148,7 @@ export namespace LSPServer {
|
||||
export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
roots: SimpleRoots([
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"pyrightconfig.json",
|
||||
]),
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(_, root) {
|
||||
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
|
||||
cwd: root,
|
||||
@@ -187,7 +166,7 @@ export namespace LSPServer {
|
||||
export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
roots: SimpleRoots(["mix.exs", "mix.lock"]),
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(_, root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
if (!binary) {
|
||||
@@ -242,7 +221,7 @@ export namespace LSPServer {
|
||||
export const Zls: Info = {
|
||||
id: "zls",
|
||||
extensions: [".zig", ".zon"],
|
||||
roots: SimpleRoots(["build.zig"]),
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("zls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { LSP } from "../lsp"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Mode } from "../session/mode"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -447,6 +448,7 @@ export namespace Server {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
mode: z.string(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
}),
|
||||
),
|
||||
@@ -681,6 +683,26 @@ export namespace Server {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/mode",
|
||||
describeRoute({
|
||||
description: "List all modes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of modes",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Mode.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const modes = await Mode.list()
|
||||
return c.json(modes)
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "ai"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
@@ -29,16 +30,20 @@ import type { ModelsDev } from "../provider/models"
|
||||
import { Share } from "../share/share"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "../storage/storage"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { FileTime } from "../file/time"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Mode } from "./mode"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
@@ -281,13 +286,12 @@ export namespace Session {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
mode?: string
|
||||
parts: MessageV2.UserPart[]
|
||||
system?: string[]
|
||||
tools?: Tool.Info[]
|
||||
}) {
|
||||
using abort = lock(input.sessionID)
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const session = await get(input.sessionID)
|
||||
@@ -317,15 +321,13 @@ export namespace Session {
|
||||
}
|
||||
|
||||
const previous = msgs.at(-1) as MessageV2.Assistant
|
||||
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX)
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous) {
|
||||
const tokens =
|
||||
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
|
||||
) {
|
||||
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
@@ -335,6 +337,8 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||
|
||||
@@ -345,34 +349,75 @@ export namespace Session {
|
||||
const url = new URL(part.url)
|
||||
switch (url.protocol) {
|
||||
case "file:":
|
||||
const filepath = path.join(app.path.cwd, url.pathname)
|
||||
let file = Bun.file(filepath)
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
const relativePath = pathname.replace(app.path.cwd, ".")
|
||||
const filePath = path.join(app.path.cwd, relativePath)
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
let text = await file.text()
|
||||
let offset: number | undefined = undefined
|
||||
let limit: number | undefined = undefined
|
||||
const range = {
|
||||
start: url.searchParams.get("start"),
|
||||
end: url.searchParams.get("end"),
|
||||
}
|
||||
if (range.start != null && part.mime === "text/plain") {
|
||||
const lines = text.split("\n")
|
||||
const start = parseInt(range.start)
|
||||
const end = range.end ? parseInt(range.end) : lines.length
|
||||
text = lines.slice(start, end).join("\n")
|
||||
if (range.start != null) {
|
||||
const filePath = part.url.split("?")[0]
|
||||
let start = parseInt(range.start)
|
||||
let end = range.end ? parseInt(range.end) : undefined
|
||||
// some LSP servers (eg, gopls) don't give full range in
|
||||
// workspace/symbol searches, so we'll try to find the
|
||||
// symbol in the document to get the full range
|
||||
if (start === end) {
|
||||
const symbols = await LSP.documentSymbol(filePath)
|
||||
for (const symbol of symbols) {
|
||||
let range: LSP.Range | undefined
|
||||
if ("range" in symbol) {
|
||||
range = symbol.range
|
||||
} else if ("location" in symbol) {
|
||||
range = symbol.location.range
|
||||
}
|
||||
if (range?.start?.line && range?.start?.line === start) {
|
||||
start = range.start.line
|
||||
end = range?.end?.line ?? start
|
||||
break
|
||||
}
|
||||
}
|
||||
offset = Math.max(start - 2, 0)
|
||||
if (end) {
|
||||
limit = end - offset + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
const args = { filePath, offset, limit }
|
||||
const result = await ReadTool.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: "", // read tool doesn't use message ID
|
||||
metadata: async () => {},
|
||||
})
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
|
||||
synthetic: true,
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: result.output,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
let file = Bun.file(filePath)
|
||||
FileTime.read(input.sessionID, filePath)
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${pathname}\"}`,
|
||||
synthetic: true,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
@@ -386,6 +431,14 @@ export namespace Session {
|
||||
return [part]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
|
||||
if (input.mode === "plan")
|
||||
input.parts.push({
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
generateText({
|
||||
maxOutputTokens: input.providerID === "google" ? 1024 : 20,
|
||||
@@ -431,9 +484,13 @@ export namespace Session {
|
||||
await updateMessage(msg)
|
||||
msgs.push(msg)
|
||||
|
||||
const system = input.system ?? SystemPrompt.provider(input.providerID, input.modelID)
|
||||
const mode = await Mode.get(input.mode ?? "build")
|
||||
let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID)
|
||||
system.push(...(await SystemPrompt.environment()))
|
||||
system.push(...(await SystemPrompt.custom()))
|
||||
// max 2 system prompt messages for caching purposes
|
||||
const [first, ...rest] = system
|
||||
system = [first, rest.join("\n")]
|
||||
|
||||
const next: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
@@ -462,7 +519,8 @@ export namespace Session {
|
||||
const tools: Record<string, AITool> = {}
|
||||
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
tools[item.id.replaceAll(".", "_")] = tool({
|
||||
if (mode.tools[item.id] === false) continue
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
description: item.description,
|
||||
inputSchema: item.parameters as ZodSchema,
|
||||
@@ -494,6 +552,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
if (mode.tools[key] === false) continue
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
item.execute = async (args, opts) => {
|
||||
@@ -523,7 +582,7 @@ export namespace Session {
|
||||
const result = streamText({
|
||||
onError() {},
|
||||
maxRetries: 10,
|
||||
maxOutputTokens: Math.max(0, model.info.limit.output) || undefined,
|
||||
maxOutputTokens: outputLimit,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
providerOptions: model.info.options,
|
||||
|
||||
@@ -76,6 +76,7 @@ export namespace MessageV2 {
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
synthetic: z.boolean().optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "TextPart",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mergeDeep } from "remeda"
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Mode {
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: z.record(z.boolean()),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Mode",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
const state = App.state("mode", async () => {
|
||||
const cfg = await Config.get()
|
||||
const mode = mergeDeep(
|
||||
{
|
||||
build: {},
|
||||
plan: {
|
||||
tools: {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
bash: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg.mode ?? {},
|
||||
)
|
||||
const result: Record<string, Info> = {}
|
||||
for (const [key, value] of Object.entries(mode)) {
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
tools: {},
|
||||
}
|
||||
const model = value.model ?? cfg.model
|
||||
if (model) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
const modelID = rest.join("/")
|
||||
item.model = {
|
||||
modelID,
|
||||
providerID,
|
||||
}
|
||||
}
|
||||
if (value.prompt) item.prompt = await Bun.file(value.prompt).text()
|
||||
if (value.tools) item.tools = value.tools
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export async function get(mode: string) {
|
||||
return state().then((x) => x[mode])
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => Object.values(x))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,6 +26,21 @@ export namespace Filesystem {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
|
||||
const { targets, start, stop } = options
|
||||
let current = start
|
||||
while (true) {
|
||||
for (const target of targets) {
|
||||
const search = join(current, target)
|
||||
if (await exists(search)) yield search
|
||||
}
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export async function globUp(pattern: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
|
||||
@@ -27,6 +27,7 @@ func main() {
|
||||
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
var mode *string = flag.String("mode", "", "mode to begin with")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
@@ -39,6 +40,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
modesStr := os.Getenv("OPENCODE_MODES")
|
||||
var modes []opencode.Mode
|
||||
err = json.Unmarshal([]byte(modesStr), &modes)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal modes", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
@@ -47,7 +56,7 @@ func main() {
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfo)
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
@@ -60,7 +69,7 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -22,20 +22,26 @@ 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
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
compactCancel context.CancelFunc
|
||||
Info opencode.App
|
||||
Modes []opencode.Mode
|
||||
Providers []opencode.Provider
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.MessageUnion
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
IntitialMode *string
|
||||
compactCancel context.CancelFunc
|
||||
IsLeaderSequence bool
|
||||
}
|
||||
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
@@ -50,6 +56,9 @@ type SendMsg struct {
|
||||
Text string
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.MessageUnion
|
||||
}
|
||||
@@ -61,9 +70,11 @@ func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo opencode.App,
|
||||
modes []opencode.Mode,
|
||||
httpClient *opencode.Client,
|
||||
model *string,
|
||||
prompt *string,
|
||||
initialModel *string,
|
||||
initialPrompt *string,
|
||||
initialMode *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
@@ -84,14 +95,36 @@ func New(
|
||||
config.SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]config.ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
if configInfo.Model != "" {
|
||||
splits := strings.Split(configInfo.Model, "/")
|
||||
appState.Provider = splits[0]
|
||||
appState.Model = strings.Join(splits[1:], "/")
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
if appState.Mode != "" {
|
||||
modeName = appState.Mode
|
||||
}
|
||||
if initialMode != nil && *initialMode != "" {
|
||||
modeName = *initialMode
|
||||
}
|
||||
for i, m := range modes {
|
||||
if m.Name == modeName {
|
||||
modeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = config.ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
}
|
||||
|
||||
if err := theme.LoadThemesFromDirectories(
|
||||
@@ -116,16 +149,20 @@ func New(
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Modes: modes,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
ModeIndex: modeIndex,
|
||||
Mode: mode,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []opencode.MessageUnion{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: model,
|
||||
InitialPrompt: prompt,
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
IntitialMode: initialMode,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@@ -159,6 +196,45 @@ func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
}
|
||||
a.Mode = &a.Modes[a.ModeIndex]
|
||||
|
||||
modelID := a.Mode.Model.ModelID
|
||||
providerID := a.Mode.Model.ProviderID
|
||||
if modelID == "" {
|
||||
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
|
||||
modelID = model.ModelID
|
||||
providerID = model.ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
if modelID != "" {
|
||||
for _, provider := range a.Providers {
|
||||
if provider.ID == providerID {
|
||||
a.Provider = &provider
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
a.Model = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
|
||||
return a, func() tea.Msg {
|
||||
a.SaveState()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||
if err != nil {
|
||||
@@ -195,6 +271,14 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Providers = providers
|
||||
|
||||
// retains backwards compatibility with old state format
|
||||
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
|
||||
a.State.Provider = model.ProviderID
|
||||
a.State.Model = model.ModelID
|
||||
}
|
||||
|
||||
var currentProvider *opencode.Provider
|
||||
var currentModel *opencode.Model
|
||||
for _, provider := range providers {
|
||||
@@ -319,10 +403,14 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
a.compactCancel = nil
|
||||
}()
|
||||
|
||||
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
_, err := a.Client.Session.Summarize(
|
||||
compactCtx,
|
||||
a.Session.ID,
|
||||
opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if compactCtx.Err() != context.Canceled {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
@@ -414,6 +502,7 @@ func (a *App) SendChatMessage(
|
||||
Parts: opencode.F(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
|
||||
@@ -79,7 +79,9 @@ func initialize() error {
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
slog.Warn("No clipboard utility found on system. Copy/paste functionality will be disabled.")
|
||||
slog.Warn(
|
||||
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
|
||||
)
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
@@ -138,7 +140,8 @@ func readText(tool struct {
|
||||
// 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")) {
|
||||
if bytes.Contains(targets, []byte("image/png")) &&
|
||||
!bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
@@ -168,7 +171,8 @@ func readImage(tool struct {
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
if len(out) < 8 ||
|
||||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchModeCommand CommandName = "switch_mode"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
@@ -152,6 +153,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: []string{"help"},
|
||||
},
|
||||
{
|
||||
Name: SwitchModeCommand,
|
||||
Description: "switch mode",
|
||||
Keybindings: parseBindings("tab"),
|
||||
Trigger: []string{"mode"},
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
|
||||
@@ -29,17 +29,26 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||
return "no matching commands"
|
||||
}
|
||||
|
||||
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
||||
func (c *CommandCompletionProvider) getCommandCompletionItem(
|
||||
cmd commands.Command,
|
||||
space int,
|
||||
t theme.Theme,
|
||||
) dialog.CompletionItemI {
|
||||
spacer := strings.Repeat(" ", space)
|
||||
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Render(spacer+cmd.Description)
|
||||
value := string(cmd.Name)
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: value,
|
||||
Title: title,
|
||||
Value: value,
|
||||
ProviderID: c.GetId(),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
func (c *CommandCompletionProvider) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
t := theme.CurrentTheme()
|
||||
commands := c.app.Commands
|
||||
|
||||
@@ -60,7 +69,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
items = append(items, getCommandCompletionItem(cmd, space, t))
|
||||
items = append(items, c.getCommandCompletionItem(cmd, space, t))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -77,7 +86,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
||||
// Add all triggers as searchable options
|
||||
for _, trigger := range cmd.Trigger {
|
||||
commandNames = append(commandNames, trigger)
|
||||
commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
|
||||
commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -14,20 +14,20 @@ import (
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type filesAndFoldersContextGroup struct {
|
||||
type filesContextGroup struct {
|
||||
app *app.App
|
||||
gitFiles []dialog.CompletionItemI
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetId() string {
|
||||
func (cg *filesContextGroup) GetId() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||
func (cg *filesContextGroup) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
t := theme.CurrentTheme()
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
base := styles.NewStyle().Background(t.BackgroundElement())
|
||||
@@ -50,8 +50,10 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
title += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||
}
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: file.Path,
|
||||
Title: title,
|
||||
Value: file.Path,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -60,7 +62,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
return items
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
||||
func (cg *filesContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
@@ -94,8 +96,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
||||
}
|
||||
if !exists {
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: file,
|
||||
Value: file,
|
||||
Title: file,
|
||||
Value: file,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -104,8 +108,8 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
cg := &filesAndFoldersContextGroup{
|
||||
func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
cg := &filesContextGroup{
|
||||
app: app,
|
||||
}
|
||||
go func() {
|
||||
@@ -0,0 +1,118 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type symbolsContextGroup struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (cg *symbolsContextGroup) GetId() string {
|
||||
return "symbols"
|
||||
}
|
||||
|
||||
func (cg *symbolsContextGroup) GetEmptyMessage() string {
|
||||
return "no matching symbols"
|
||||
}
|
||||
|
||||
type SymbolKind int
|
||||
|
||||
const (
|
||||
SymbolKindFile SymbolKind = 1
|
||||
SymbolKindModule SymbolKind = 2
|
||||
SymbolKindNamespace SymbolKind = 3
|
||||
SymbolKindPackage SymbolKind = 4
|
||||
SymbolKindClass SymbolKind = 5
|
||||
SymbolKindMethod SymbolKind = 6
|
||||
SymbolKindProperty SymbolKind = 7
|
||||
SymbolKindField SymbolKind = 8
|
||||
SymbolKindConstructor SymbolKind = 9
|
||||
SymbolKindEnum SymbolKind = 10
|
||||
SymbolKindInterface SymbolKind = 11
|
||||
SymbolKindFunction SymbolKind = 12
|
||||
SymbolKindVariable SymbolKind = 13
|
||||
SymbolKindConstant SymbolKind = 14
|
||||
SymbolKindString SymbolKind = 15
|
||||
SymbolKindNumber SymbolKind = 16
|
||||
SymbolKindBoolean SymbolKind = 17
|
||||
SymbolKindArray SymbolKind = 18
|
||||
SymbolKindObject SymbolKind = 19
|
||||
SymbolKindKey SymbolKind = 20
|
||||
SymbolKindNull SymbolKind = 21
|
||||
SymbolKindEnumMember SymbolKind = 22
|
||||
SymbolKindStruct SymbolKind = 23
|
||||
SymbolKindEvent SymbolKind = 24
|
||||
SymbolKindOperator SymbolKind = 25
|
||||
SymbolKindTypeParameter SymbolKind = 26
|
||||
)
|
||||
|
||||
func (cg *symbolsContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
symbols, err := cg.app.Client.Find.Symbols(
|
||||
context.Background(),
|
||||
opencode.FindSymbolsParams{Query: opencode.F(query)},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get symbol completion items", "error", err)
|
||||
return items, err
|
||||
}
|
||||
if symbols == nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
|
||||
base := baseStyle.Render
|
||||
muted := baseStyle.Foreground(t.TextMuted()).Render
|
||||
|
||||
for _, sym := range *symbols {
|
||||
parts := strings.Split(sym.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
title := base(lastPart)
|
||||
|
||||
uriParts := strings.Split(sym.Location.Uri, "/")
|
||||
lastTwoParts := uriParts[len(uriParts)-2:]
|
||||
joined := strings.Join(lastTwoParts, "/")
|
||||
title += muted(fmt.Sprintf(" %s", joined))
|
||||
|
||||
start := int(sym.Location.Range.Start.Line)
|
||||
end := int(sym.Location.Range.End.Line)
|
||||
title += muted(fmt.Sprintf(":L%d-%d", start, end))
|
||||
|
||||
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
|
||||
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: value,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: sym,
|
||||
})
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
return &symbolsContextGroup{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -38,6 +39,7 @@ type EditorComponent interface {
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
SetValue(value string)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
SetExitKeyInDebounce(inDebounce bool)
|
||||
}
|
||||
@@ -96,7 +98,16 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ".pdf":
|
||||
mediaType = "application/pdf"
|
||||
default:
|
||||
mediaType = "text/plain"
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
Filename: filePath,
|
||||
MediaType: "text/plain",
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
@@ -127,13 +138,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
text := string(msg)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = m.resetTextareaStyles()
|
||||
m.textarea = updateTextareaStyles(m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||
case dialog.CompletionSelectedMsg:
|
||||
switch msg.ProviderID {
|
||||
switch msg.Item.GetProviderID() {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
commandName := strings.TrimPrefix(msg.Item.GetValue(), "/")
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -143,7 +154,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.CompletionValue + " ")
|
||||
m.textarea.InsertString(msg.Item.GetValue() + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -154,7 +165,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Now, insert the attachment at the position where the '@' was.
|
||||
// The cursor is now at `atIndex` after the replacement.
|
||||
filePath := msg.CompletionValue
|
||||
filePath := msg.Item.GetValue()
|
||||
extension := filepath.Ext(filePath)
|
||||
mediaType := ""
|
||||
switch extension {
|
||||
@@ -170,22 +181,39 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
existingValue := m.textarea.Value()
|
||||
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||
if lastSpaceIndex == -1 {
|
||||
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||
} else {
|
||||
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
|
||||
m.textarea.SetValue(modifiedValue + " ")
|
||||
case "symbols":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.GetValue() + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
symbol := msg.Item.GetRaw().(opencode.Symbol)
|
||||
parts := strings.Split(symbol.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + lastPart,
|
||||
URL: msg.Item.GetValue(),
|
||||
Filename: lastPart,
|
||||
MediaType: "text/plain",
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID())
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -214,13 +242,17 @@ func (m *editorComponent) Content(width int) string {
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(t.Border()).
|
||||
BorderForeground(borderForeground).
|
||||
BorderBackground(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
@@ -375,6 +407,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||
m.interruptKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetValue(value string) {
|
||||
m.textarea.SetValue(value)
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
|
||||
m.exitKeyInDebounce = inDebounce
|
||||
}
|
||||
@@ -391,14 +427,12 @@ func (m *editorComponent) getExitKeyText() string {
|
||||
return m.app.Commands[commands.AppExitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) resetTextareaStyles() textarea.Model {
|
||||
func updateTextareaStyles(ta textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ta := m.textarea
|
||||
|
||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
@@ -446,6 +480,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
ta = updateTextareaStyles(ta)
|
||||
|
||||
m := &editorComponent{
|
||||
app: app,
|
||||
@@ -453,7 +488,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
}
|
||||
m.resetTextareaStyles()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
@@ -371,11 +372,65 @@ func (m *messagesComponent) header(width int) string {
|
||||
headerLines,
|
||||
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
|
||||
)
|
||||
|
||||
share := ""
|
||||
if m.app.Session.Share.URL != "" {
|
||||
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
|
||||
share = muted(m.app.Session.Share.URL + " /unshare")
|
||||
} else {
|
||||
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
|
||||
share = base("/share") + muted(" to create a shareable link")
|
||||
}
|
||||
|
||||
sessionInfo := ""
|
||||
tokens := float64(0)
|
||||
cost := float64(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if assistant, ok := message.(opencode.AssistantMessage); ok {
|
||||
cost += assistant.Cost
|
||||
usage := assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
if assistant.Summary {
|
||||
tokens = usage.Output
|
||||
continue
|
||||
}
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current model is a subscription model (cost is 0 for both input and output)
|
||||
isSubscriptionModel := m.app.Model != nil &&
|
||||
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
|
||||
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.Background()).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
|
||||
|
||||
background := t.Background()
|
||||
share = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: width - 6,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: share,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: sessionInfo,
|
||||
},
|
||||
)
|
||||
|
||||
headerLines = append(headerLines, share)
|
||||
|
||||
header := strings.Join(headerLines, "\n")
|
||||
|
||||
header = styles.NewStyle().
|
||||
@@ -393,6 +448,50 @@ func (m *messagesComponent) header(width int) string {
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
|
||||
func formatTokensAndCost(
|
||||
tokens float64,
|
||||
contextWindow float64,
|
||||
cost float64,
|
||||
isSubscriptionModel bool,
|
||||
) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
if isSubscriptionModel {
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%%",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
)
|
||||
}
|
||||
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%% (%s)",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) View(width, height int) string {
|
||||
t := theme.CurrentTheme()
|
||||
if m.rendering {
|
||||
|
||||
@@ -2,12 +2,15 @@ package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -15,32 +18,35 @@ import (
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
Title string
|
||||
Value string
|
||||
Title string
|
||||
Value string
|
||||
ProviderID string
|
||||
Raw any
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
list.ListItem
|
||||
GetValue() string
|
||||
DisplayValue() string
|
||||
GetProviderID() string
|
||||
GetRaw() any
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
Padding(0, 1)
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
title := itemStyle.Render(
|
||||
ci.DisplayValue(),
|
||||
)
|
||||
title := itemStyle.Render(truncatedStr)
|
||||
return title
|
||||
}
|
||||
|
||||
@@ -52,6 +58,14 @@ func (ci *CompletionItem) GetValue() string {
|
||||
return ci.Value
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) GetProviderID() string {
|
||||
return ci.ProviderID
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) GetRaw() any {
|
||||
return ci.Raw
|
||||
}
|
||||
|
||||
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
|
||||
return &completionItem
|
||||
}
|
||||
@@ -63,9 +77,8 @@ type CompletionProvider interface {
|
||||
}
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
ProviderID string
|
||||
Item CompletionItemI
|
||||
SearchString string
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
@@ -83,7 +96,7 @@ type CompletionDialog interface {
|
||||
|
||||
type completionDialogComponent struct {
|
||||
query string
|
||||
completionProvider CompletionProvider
|
||||
providers []CompletionProvider
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
@@ -109,6 +122,52 @@ func (c *completionDialogComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
allItems := make([]CompletionItemI, 0)
|
||||
|
||||
// Collect results from all providers
|
||||
for _, provider := range c.providers {
|
||||
items, err := provider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"Failed to get completion items",
|
||||
"provider",
|
||||
provider.GetId(),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
|
||||
// If there's a query, use fuzzy ranking to sort results
|
||||
if query != "" && len(allItems) > 0 {
|
||||
// Create a slice of display values for fuzzy matching
|
||||
displayValues := make([]string, len(allItems))
|
||||
for i, item := range allItems {
|
||||
displayValues[i] = item.DisplayValue()
|
||||
}
|
||||
|
||||
// Get fuzzy matches with ranking
|
||||
matches := fuzzy.RankFindFold(query, displayValues)
|
||||
|
||||
// Sort by score (best matches first)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Reorder items based on fuzzy ranking
|
||||
rankedItems := make([]CompletionItemI, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
|
||||
}
|
||||
|
||||
return rankedItems
|
||||
}
|
||||
|
||||
return allItems
|
||||
}
|
||||
}
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
@@ -126,14 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
cmd = func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, c.getAllCompletions(query))
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
@@ -149,23 +201,18 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
|
||||
width := lipgloss.Width(value)
|
||||
triggerWidth := lipgloss.Width(c.trigger)
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
cmd := func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, c.getAllCompletions(""))
|
||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
@@ -177,19 +224,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
maxWidth := 40
|
||||
completions := c.list.GetItems()
|
||||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
width := lipgloss.Width(title)
|
||||
if width > maxWidth-4 {
|
||||
maxWidth = width + 4
|
||||
}
|
||||
}
|
||||
|
||||
c.list.SetMaxWidth(maxWidth)
|
||||
c.list.SetMaxWidth(c.width)
|
||||
|
||||
return baseStyle.
|
||||
Padding(0, 0).
|
||||
@@ -213,12 +248,10 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
||||
|
||||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
ProviderID: c.completionProvider.GetId(),
|
||||
SearchString: value,
|
||||
Item: item,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
@@ -230,32 +263,53 @@ func (c *completionDialogComponent) close() tea.Cmd {
|
||||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
|
||||
func NewCompletionDialogComponent(
|
||||
trigger string,
|
||||
providers ...CompletionProvider,
|
||||
) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
ti.SetValue(trigger)
|
||||
|
||||
// Use a generic empty message if we have multiple providers
|
||||
emptyMessage := "no matching items"
|
||||
if len(providers) == 1 {
|
||||
emptyMessage = providers[0].GetEmptyMessage()
|
||||
}
|
||||
|
||||
li := list.NewListComponent(
|
||||
[]CompletionItemI{},
|
||||
7,
|
||||
completionProvider.GetEmptyMessage(),
|
||||
emptyMessage,
|
||||
false,
|
||||
)
|
||||
|
||||
go func() {
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
li.SetItems(items)
|
||||
}()
|
||||
|
||||
// Initialize the textarea with the trigger character
|
||||
ti.SetValue(trigger)
|
||||
|
||||
return &completionDialogComponent{
|
||||
c := &completionDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
providers: providers,
|
||||
pseudoSearchTextArea: ti,
|
||||
list: li,
|
||||
trigger: trigger,
|
||||
}
|
||||
|
||||
// Load initial items from all providers
|
||||
go func() {
|
||||
allItems := make([]CompletionItemI, 0)
|
||||
for _, provider := range providers {
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"Failed to get completion items",
|
||||
"provider",
|
||||
provider.GetId(),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
li.SetItems(allItems)
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
@@ -20,6 +21,7 @@ type StatusComponent interface {
|
||||
type statusComponent struct {
|
||||
app *app.App
|
||||
width int
|
||||
cwd string
|
||||
}
|
||||
|
||||
func (m statusComponent) Init() tea.Cmd {
|
||||
@@ -53,38 +55,6 @@ func (m statusComponent) logo() string {
|
||||
Render(open + code + version)
|
||||
}
|
||||
|
||||
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
// Format cost with $ symbol and 2 decimal places
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Context: %s (%d%%), Cost: %s",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
)
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
logo := m.logo()
|
||||
@@ -93,48 +63,70 @@ func (m statusComponent) View() string {
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Padding(0, 1).
|
||||
Render(m.app.Info.Path.Cwd)
|
||||
Render(m.cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.ID != "" {
|
||||
tokens := float64(0)
|
||||
cost := float64(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if assistant, ok := message.(opencode.AssistantMessage); ok {
|
||||
cost += assistant.Cost
|
||||
usage := assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
if assistant.Summary {
|
||||
tokens = usage.Output
|
||||
continue
|
||||
}
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundElement()).
|
||||
Padding(0, 1).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost))
|
||||
var modeBackground compat.AdaptiveColor
|
||||
var modeForeground compat.AdaptiveColor
|
||||
switch m.app.ModeIndex {
|
||||
case 0:
|
||||
modeBackground = t.BackgroundElement()
|
||||
modeForeground = t.TextMuted()
|
||||
case 1:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 2:
|
||||
modeBackground = t.Accent()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 3:
|
||||
modeBackground = t.Success()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 4:
|
||||
modeBackground = t.Warning()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 5:
|
||||
modeBackground = t.Primary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 6:
|
||||
modeBackground = t.Error()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
default:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
}
|
||||
|
||||
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
|
||||
command := m.app.Commands[commands.SwitchModeCommand]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = m.app.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
|
||||
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
|
||||
modeNameStyle := modeStyle.Bold(true).Render
|
||||
modeDescStyle := modeStyle.Render
|
||||
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
|
||||
mode = modeStyle.
|
||||
Padding(0, 1).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(modeBackground).
|
||||
BorderBackground(t.BackgroundPanel()).
|
||||
Render(mode)
|
||||
|
||||
mode = styles.NewStyle().
|
||||
Faint(true).
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(key+" ") +
|
||||
mode
|
||||
|
||||
space := max(
|
||||
0,
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
|
||||
)
|
||||
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
|
||||
|
||||
status := logo + cwd + spacer + sessionInfo
|
||||
status := logo + cwd + spacer + mode
|
||||
|
||||
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
@@ -145,5 +137,12 @@ func NewStatusCmp(app *app.App) StatusComponent {
|
||||
app: app,
|
||||
}
|
||||
|
||||
homePath, err := os.UserHomeDir()
|
||||
cwdPath := app.Info.Path.Cwd
|
||||
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
|
||||
cwdPath = "~" + cwdPath[len(homePath):]
|
||||
}
|
||||
statusComponent.cwd = cwdPath
|
||||
|
||||
return statusComponent
|
||||
}
|
||||
|
||||
@@ -931,7 +931,6 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
|
||||
}
|
||||
|
||||
// CursorDown moves the cursor down by one line.
|
||||
// Returns whether or not the cursor blink should be reset.
|
||||
func (m *Model) CursorDown() {
|
||||
li := m.LineInfo()
|
||||
charOffset := max(m.lastCharOffset, li.CharOffset)
|
||||
@@ -940,11 +939,73 @@ func (m *Model) CursorDown() {
|
||||
if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
|
||||
// Move to the next model line
|
||||
m.row++
|
||||
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
|
||||
|
||||
// We want to land on the first wrapped line of the new model line.
|
||||
grid := m.memoizedWrap(m.value[m.row], m.width)
|
||||
targetLineContent := grid[0]
|
||||
|
||||
// Find position within the first wrapped line.
|
||||
offset := 0
|
||||
colInLine := 0
|
||||
for i, item := range targetLineContent {
|
||||
var itemWidth int
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
// Decide whether to stick with the previous index or move to the current
|
||||
// one based on which is closer to the target offset.
|
||||
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
|
||||
colInLine = i + 1
|
||||
} else {
|
||||
colInLine = i
|
||||
}
|
||||
goto foundNextLine
|
||||
}
|
||||
offset += itemWidth
|
||||
}
|
||||
colInLine = len(targetLineContent)
|
||||
foundNextLine:
|
||||
m.col = colInLine // startCol is 0 for the first wrapped line
|
||||
} else if li.RowOffset+1 < li.Height {
|
||||
// Move to the next wrapped line within the same model line
|
||||
startOfNextWrappedLine := li.StartColumn + li.Width
|
||||
m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset)
|
||||
grid := m.memoizedWrap(m.value[m.row], m.width)
|
||||
targetLineContent := grid[li.RowOffset+1]
|
||||
|
||||
startCol := 0
|
||||
for i := 0; i < li.RowOffset+1; i++ {
|
||||
startCol += len(grid[i])
|
||||
}
|
||||
|
||||
// Find position within the target wrapped line.
|
||||
offset := 0
|
||||
colInLine := 0
|
||||
for i, item := range targetLineContent {
|
||||
var itemWidth int
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
// Decide whether to stick with the previous index or move to the current
|
||||
// one based on which is closer to the target offset.
|
||||
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
|
||||
colInLine = i + 1
|
||||
} else {
|
||||
colInLine = i
|
||||
}
|
||||
goto foundSameLine
|
||||
}
|
||||
offset += itemWidth
|
||||
}
|
||||
colInLine = len(targetLineContent)
|
||||
foundSameLine:
|
||||
m.col = startCol + colInLine
|
||||
}
|
||||
m.SetCursorColumn(m.col)
|
||||
}
|
||||
@@ -956,22 +1017,77 @@ func (m *Model) CursorUp() {
|
||||
m.lastCharOffset = charOffset
|
||||
|
||||
if li.RowOffset <= 0 && m.row > 0 {
|
||||
// Move to the previous model line
|
||||
// Move to the previous model line. We want to land on the last wrapped
|
||||
// line of the previous model line.
|
||||
m.row--
|
||||
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
|
||||
} else if li.RowOffset > 0 {
|
||||
// Move to the previous wrapped line within the same model line
|
||||
// To do this, we need to find the start of the previous wrapped line.
|
||||
prevLineInfo := m.LineInfo()
|
||||
// prevLineStart := 0
|
||||
if prevLineInfo.RowOffset > 0 {
|
||||
// This is complex, so we'll approximate by moving to the start of the current wrapped line
|
||||
// and then letting characterLeft handle it. A more precise calculation would
|
||||
// require re-wrapping to find the previous line's start.
|
||||
// For now, a simpler approach:
|
||||
m.col = li.StartColumn - 1
|
||||
grid := m.memoizedWrap(m.value[m.row], m.width)
|
||||
targetLineContent := grid[len(grid)-1]
|
||||
|
||||
// Find start of last wrapped line.
|
||||
startCol := len(m.value[m.row]) - len(targetLineContent)
|
||||
|
||||
// Find position within the last wrapped line.
|
||||
offset := 0
|
||||
colInLine := 0
|
||||
for i, item := range targetLineContent {
|
||||
var itemWidth int
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
// Decide whether to stick with the previous index or move to the current
|
||||
// one based on which is closer to the target offset.
|
||||
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
|
||||
colInLine = i + 1
|
||||
} else {
|
||||
colInLine = i
|
||||
}
|
||||
goto foundPrevLine
|
||||
}
|
||||
offset += itemWidth
|
||||
}
|
||||
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
|
||||
colInLine = len(targetLineContent)
|
||||
foundPrevLine:
|
||||
m.col = startCol + colInLine
|
||||
} else if li.RowOffset > 0 {
|
||||
// Move to the previous wrapped line within the same model line.
|
||||
grid := m.memoizedWrap(m.value[m.row], m.width)
|
||||
targetLineContent := grid[li.RowOffset-1]
|
||||
|
||||
startCol := 0
|
||||
for i := 0; i < li.RowOffset-1; i++ {
|
||||
startCol += len(grid[i])
|
||||
}
|
||||
|
||||
// Find position within the target wrapped line.
|
||||
offset := 0
|
||||
colInLine := 0
|
||||
for i, item := range targetLineContent {
|
||||
var itemWidth int
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
// Decide whether to stick with the previous index or move to the current
|
||||
// one based on which is closer to the target offset.
|
||||
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
|
||||
colInLine = i + 1
|
||||
} else {
|
||||
colInLine = i
|
||||
}
|
||||
goto foundSameLine
|
||||
}
|
||||
offset += itemWidth
|
||||
}
|
||||
colInLine = len(targetLineContent)
|
||||
foundSameLine:
|
||||
m.col = startCol + colInLine
|
||||
}
|
||||
m.SetCursorColumn(m.col)
|
||||
}
|
||||
@@ -1591,7 +1707,7 @@ func (m Model) View() string {
|
||||
// guaranteed to be a space since any other character would
|
||||
// have been wrapped.
|
||||
wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
|
||||
padding -= m.width - strwidth
|
||||
padding = m.width - uniseg.StringWidth(wrappedLineStr)
|
||||
}
|
||||
|
||||
if m.row == l && lineInfo.RowOffset == wl {
|
||||
@@ -1903,6 +2019,16 @@ func (m *Model) splitLine(row, col int) {
|
||||
m.row++
|
||||
}
|
||||
|
||||
func itemWidth(item any) int {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
return rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
return uniseg.StringWidth(v.Display)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func wrapInterfaces(content []any, width int) [][]any {
|
||||
if width <= 0 {
|
||||
return [][]any{content}
|
||||
@@ -1945,19 +2071,18 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
}
|
||||
inSpaces = true
|
||||
spaceW += itemW
|
||||
} else {
|
||||
} else { // It's not a space, it's a character for a word.
|
||||
if inSpaces {
|
||||
// End of spaces
|
||||
if lineW > 0 && lineW+spaceW > width {
|
||||
lines = append(lines, []any{})
|
||||
lineW = 0
|
||||
} else {
|
||||
lineW += spaceW
|
||||
}
|
||||
// Add spaces to current line
|
||||
// We just finished a block of spaces. Handle them now.
|
||||
lineW += spaceW
|
||||
for i := 0; i < spaceW; i++ {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
|
||||
}
|
||||
if lineW > width {
|
||||
// The spaces made the line overflow. Start a new line for the upcoming word.
|
||||
lines = append(lines, []any{})
|
||||
lineW = 0
|
||||
}
|
||||
spaceW = 0
|
||||
}
|
||||
inSpaces = false
|
||||
@@ -1966,20 +2091,24 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining word/spaces
|
||||
// Handle any remaining word/spaces at the end of the content.
|
||||
if wordW > 0 {
|
||||
if lineW > 0 && lineW+wordW > width {
|
||||
lines = append(lines, word)
|
||||
lineW = wordW
|
||||
} else {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
|
||||
lineW += wordW
|
||||
}
|
||||
}
|
||||
if spaceW > 0 {
|
||||
if lineW > 0 && lineW+spaceW > width {
|
||||
lines = append(lines, []any{})
|
||||
}
|
||||
// There are trailing spaces. Add them.
|
||||
for i := 0; i < spaceW; i++ {
|
||||
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
|
||||
lineW += 1
|
||||
}
|
||||
if lineW > width {
|
||||
lines = append(lines, []any{})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,18 +16,27 @@ type ModelUsage struct {
|
||||
LastUsed time.Time `toml:"last_used"`
|
||||
}
|
||||
|
||||
type ModeModel struct {
|
||||
ProviderID string `toml:"provider_id"`
|
||||
ModelID string `toml:"model_id"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
Theme string `toml:"theme"`
|
||||
ModeModel map[string]ModeModel `toml:"mode_model"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
Mode string `toml:"mode"`
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Theme: "opencode",
|
||||
Mode: "build",
|
||||
ModeModel: make(map[string]ModeModel),
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/components/status"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -65,20 +66,21 @@ type appModel struct {
|
||||
completions dialog.CompletionDialog
|
||||
commandProvider dialog.CompletionProvider
|
||||
fileProvider dialog.CompletionProvider
|
||||
symbolsProvider dialog.CompletionProvider
|
||||
showCompletionDialog bool
|
||||
fileCompletionActive bool
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
interruptKeyState InterruptKeyState
|
||||
exitKeyState ExitKeyState
|
||||
lastScroll time.Time
|
||||
messagesRight bool
|
||||
fileViewer fileviewer.Model
|
||||
lastMouse tea.Mouse
|
||||
fileViewerStart int
|
||||
fileViewerEnd int
|
||||
fileViewerHit bool
|
||||
// isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
interruptKeyState InterruptKeyState
|
||||
exitKeyState ExitKeyState
|
||||
lastScroll time.Time
|
||||
messagesRight bool
|
||||
fileViewer fileviewer.Model
|
||||
lastMouse tea.Mouse
|
||||
fileViewerStart int
|
||||
fileViewerEnd int
|
||||
fileViewerHit bool
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -182,9 +184,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// 2. Check for commands that require leader
|
||||
if a.isLeaderSequence {
|
||||
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
||||
a.isLeaderSequence = false
|
||||
if a.app.IsLeaderSequence {
|
||||
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
|
||||
a.app.IsLeaderSequence = false
|
||||
if len(matches) > 0 {
|
||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||
}
|
||||
@@ -202,7 +204,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set command provider for command completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider, "/")
|
||||
a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -220,8 +222,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set file provider for file completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@")
|
||||
// Set both file and symbols providers for @ completion
|
||||
a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -259,21 +261,21 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// 5. Check for leader key activation
|
||||
if a.leaderBinding != nil &&
|
||||
!a.isLeaderSequence &&
|
||||
!a.app.IsLeaderSequence &&
|
||||
key.Matches(msg, *a.leaderBinding) {
|
||||
a.isLeaderSequence = true
|
||||
a.app.IsLeaderSequence = true
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// 6 Handle input clear command
|
||||
inputClearCommand := a.app.Commands[commands.InputClearCommand]
|
||||
if inputClearCommand.Matches(msg, a.isLeaderSequence) && a.editor.Length() > 0 {
|
||||
if inputClearCommand.Matches(msg, a.app.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() {
|
||||
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
|
||||
switch a.interruptKeyState {
|
||||
case InterruptKeyIdle:
|
||||
// First interrupt key press - start debounce timer
|
||||
@@ -292,7 +294,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// 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) {
|
||||
if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
|
||||
switch a.exitKeyState {
|
||||
case ExitKeyIdle:
|
||||
// First exit key press - start debounce timer
|
||||
@@ -310,10 +312,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
|
||||
if len(matches) > 0 {
|
||||
// Skip interrupt key if we're in debounce mode and app is busy
|
||||
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
|
||||
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
|
||||
return a, nil
|
||||
}
|
||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||
@@ -389,6 +391,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.showCompletionDialog = false
|
||||
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
case app.SetEditorContentMsg:
|
||||
// Set the editor content without sending
|
||||
a.editor.SetValue(msg.Text)
|
||||
updated, cmd := a.editor.Focus()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
a.showCompletionDialog = false
|
||||
a.fileCompletionActive = false
|
||||
@@ -517,8 +525,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case app.ModelSelectedMsg:
|
||||
a.app.Provider = &msg.Provider
|
||||
a.app.Model = &msg.Model
|
||||
a.app.State.Provider = msg.Provider.ID
|
||||
a.app.State.Model = msg.Model.ID
|
||||
a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
|
||||
ProviderID: msg.Provider.ID,
|
||||
ModelID: msg.Model.ID,
|
||||
}
|
||||
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
||||
a.app.SaveState()
|
||||
case dialog.ThemeSelectedMsg:
|
||||
@@ -816,6 +826,10 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
case commands.AppHelpCommand:
|
||||
helpDialog := dialog.NewHelpDialog(a.app)
|
||||
a.modal = helpDialog
|
||||
case commands.SwitchModeCommand:
|
||||
updated, cmd := a.app.SwitchMode()
|
||||
a.app = updated
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.EditorOpenCommand:
|
||||
if a.app.IsBusy() {
|
||||
// status.Warn("Agent is working, please wait...")
|
||||
@@ -857,7 +871,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
return app.SendMsg{
|
||||
return app.SetEditorContentMsg{
|
||||
Text: string(content),
|
||||
}
|
||||
})
|
||||
@@ -922,7 +936,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
a.modal = themeDialog
|
||||
case commands.FileListCommand:
|
||||
a.editor.Blur()
|
||||
provider := completions.NewFileAndFolderContextGroup(a.app)
|
||||
provider := completions.NewFileContextGroup(a.app)
|
||||
findDialog := dialog.NewFindDialog(provider)
|
||||
findDialog.SetWidth(layout.Current.Container.Width - 8)
|
||||
a.modal = findDialog
|
||||
@@ -1030,11 +1044,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
|
||||
func NewModel(app *app.App) tea.Model {
|
||||
commandProvider := completions.NewCommandCompletionProvider(app)
|
||||
fileProvider := completions.NewFileAndFolderContextGroup(app)
|
||||
fileProvider := completions.NewFileContextGroup(app)
|
||||
symbolsProvider := completions.NewSymbolsContextGroup(app)
|
||||
|
||||
messages := chat.NewMessagesComponent(app)
|
||||
editor := chat.NewEditorComponent(app)
|
||||
completions := dialog.NewCompletionDialogComponent(commandProvider, "/")
|
||||
completions := dialog.NewCompletionDialogComponent("/", commandProvider)
|
||||
|
||||
var leaderBinding *key.Binding
|
||||
if app.Config.Keybinds.Leader != "" {
|
||||
@@ -1050,8 +1065,8 @@ func NewModel(app *app.App) tea.Model {
|
||||
completions: completions,
|
||||
commandProvider: commandProvider,
|
||||
fileProvider: fileProvider,
|
||||
symbolsProvider: symbolsProvider,
|
||||
leaderBinding: leaderBinding,
|
||||
isLeaderSequence: false,
|
||||
showCompletionDialog: false,
|
||||
fileCompletionActive: false,
|
||||
toastManager: toast.NewToastManager(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
configured_endpoints: 21
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-879570c29c56e0a73a0624a84662b7f7c319a3c790c78ec6ac4cf62a7b1a5bd0.yml
|
||||
openapi_spec_hash: 2432e2dfed22193a0c6b3dfe0f82ec7d
|
||||
config_hash: 53e3aeb355f3b2e0d10985d6d7635a7e
|
||||
configured_endpoints: 22
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-37247433660e125f9a79ff173d8007f13192d28a40f4e599e002446a6ed0c128.yml
|
||||
openapi_spec_hash: 8095ebe2d88259381a58e7b0c87244c4
|
||||
config_hash: 589ec6a935a43a3c49a325ece86cbda2
|
||||
|
||||
@@ -20,12 +20,14 @@ Response Types:
|
||||
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#LogLevel">LogLevel</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>
|
||||
|
||||
Methods:
|
||||
|
||||
- <code title="get /app">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /app/init">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /log">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Log">Log</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppLogParams">AppLogParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /mode">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Modes">Modes</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
|
||||
# Find
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ func (r *AppService) Log(ctx context.Context, body AppLogParams, opts ...option.
|
||||
return
|
||||
}
|
||||
|
||||
// List all modes
|
||||
func (r *AppService) Modes(ctx context.Context, opts ...option.RequestOption) (res *[]Mode, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "mode"
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Git bool `json:"git,required"`
|
||||
Hostname string `json:"hostname,required"`
|
||||
@@ -149,6 +157,54 @@ func (r LogLevel) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type Mode struct {
|
||||
Name string `json:"name,required"`
|
||||
Tools map[string]bool `json:"tools,required"`
|
||||
Model ModeModel `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
JSON modeJSON `json:"-"`
|
||||
}
|
||||
|
||||
// modeJSON contains the JSON metadata for the struct [Mode]
|
||||
type modeJSON struct {
|
||||
Name apijson.Field
|
||||
Tools apijson.Field
|
||||
Model apijson.Field
|
||||
Prompt apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *Mode) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r modeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ModeModel struct {
|
||||
ModelID string `json:"modelID,required"`
|
||||
ProviderID string `json:"providerID,required"`
|
||||
JSON modeModelJSON `json:"-"`
|
||||
}
|
||||
|
||||
// modeModelJSON contains the JSON metadata for the struct [ModeModel]
|
||||
type modeModelJSON struct {
|
||||
ModelID apijson.Field
|
||||
ProviderID apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *ModeModel) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r modeModelJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type AppLogParams struct {
|
||||
// Log level
|
||||
Level param.Field[AppLogParamsLevel] `json:"level,required"`
|
||||
|
||||
@@ -85,3 +85,25 @@ func TestAppLogWithOptionalParams(t *testing.T) {
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppModes(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.App.Modes(context.TODO())
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ type Config struct {
|
||||
// Minimum log level to write to log files
|
||||
LogLevel LogLevel `json:"log_level"`
|
||||
// MCP (Model Context Protocol) server configurations
|
||||
Mcp map[string]ConfigMcp `json:"mcp"`
|
||||
Mcp map[string]ConfigMcp `json:"mcp"`
|
||||
Mode ConfigMode `json:"mode"`
|
||||
// Model to use in the format of provider/model, eg anthropic/claude-2
|
||||
Model string `json:"model"`
|
||||
// Custom provider configurations and model overrides
|
||||
@@ -86,6 +87,7 @@ type configJSON struct {
|
||||
Keybinds apijson.Field
|
||||
LogLevel apijson.Field
|
||||
Mcp apijson.Field
|
||||
Mode apijson.Field
|
||||
Model apijson.Field
|
||||
Provider apijson.Field
|
||||
Theme apijson.Field
|
||||
@@ -276,6 +278,77 @@ func (r ConfigMcpType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type ConfigMode struct {
|
||||
Build ConfigModeBuild `json:"build"`
|
||||
Plan ConfigModePlan `json:"plan"`
|
||||
ExtraFields map[string]ConfigMode `json:"-,extras"`
|
||||
JSON configModeJSON `json:"-"`
|
||||
}
|
||||
|
||||
// configModeJSON contains the JSON metadata for the struct [ConfigMode]
|
||||
type configModeJSON struct {
|
||||
Build apijson.Field
|
||||
Plan apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *ConfigMode) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r configModeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ConfigModeBuild struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Tools map[string]bool `json:"tools"`
|
||||
JSON configModeBuildJSON `json:"-"`
|
||||
}
|
||||
|
||||
// configModeBuildJSON contains the JSON metadata for the struct [ConfigModeBuild]
|
||||
type configModeBuildJSON struct {
|
||||
Model apijson.Field
|
||||
Prompt apijson.Field
|
||||
Tools apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *ConfigModeBuild) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r configModeBuildJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ConfigModePlan struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Tools map[string]bool `json:"tools"`
|
||||
JSON configModePlanJSON `json:"-"`
|
||||
}
|
||||
|
||||
// configModePlanJSON contains the JSON metadata for the struct [ConfigModePlan]
|
||||
type configModePlanJSON struct {
|
||||
Model apijson.Field
|
||||
Prompt apijson.Field
|
||||
Tools apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *ConfigModePlan) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r configModePlanJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ConfigProvider struct {
|
||||
Models map[string]ConfigProviderModel `json:"models,required"`
|
||||
ID string `json:"id"`
|
||||
@@ -460,6 +533,8 @@ type Keybinds struct {
|
||||
SessionShare string `json:"session_share,required"`
|
||||
// Unshare current session
|
||||
SessionUnshare string `json:"session_unshare,required"`
|
||||
// Switch mode
|
||||
SwitchMode string `json:"switch_mode,required"`
|
||||
// List available themes
|
||||
ThemeList string `json:"theme_list,required"`
|
||||
// Toggle tool details
|
||||
@@ -500,6 +575,7 @@ type keybindsJSON struct {
|
||||
SessionNew apijson.Field
|
||||
SessionShare apijson.Field
|
||||
SessionUnshare apijson.Field
|
||||
SwitchMode apijson.Field
|
||||
ThemeList apijson.Field
|
||||
ToolDetails apijson.Field
|
||||
raw string
|
||||
|
||||
+31
-22
@@ -439,11 +439,12 @@ type AssistantMessagePart struct {
|
||||
Type AssistantMessagePartType `json:"type,required"`
|
||||
ID string `json:"id"`
|
||||
// This field can have the runtime type of [ToolPartState].
|
||||
State interface{} `json:"state"`
|
||||
Text string `json:"text"`
|
||||
Tool string `json:"tool"`
|
||||
JSON assistantMessagePartJSON `json:"-"`
|
||||
union AssistantMessagePartUnion
|
||||
State interface{} `json:"state"`
|
||||
Synthetic bool `json:"synthetic"`
|
||||
Text string `json:"text"`
|
||||
Tool string `json:"tool"`
|
||||
JSON assistantMessagePartJSON `json:"-"`
|
||||
union AssistantMessagePartUnion
|
||||
}
|
||||
|
||||
// assistantMessagePartJSON contains the JSON metadata for the struct
|
||||
@@ -452,6 +453,7 @@ type assistantMessagePartJSON struct {
|
||||
Type apijson.Field
|
||||
ID apijson.Field
|
||||
State apijson.Field
|
||||
Synthetic apijson.Field
|
||||
Text apijson.Field
|
||||
Tool apijson.Field
|
||||
raw string
|
||||
@@ -815,15 +817,17 @@ func (r StepStartPartType) IsKnown() bool {
|
||||
}
|
||||
|
||||
type TextPart struct {
|
||||
Text string `json:"text,required"`
|
||||
Type TextPartType `json:"type,required"`
|
||||
JSON textPartJSON `json:"-"`
|
||||
Text string `json:"text,required"`
|
||||
Type TextPartType `json:"type,required"`
|
||||
Synthetic bool `json:"synthetic"`
|
||||
JSON textPartJSON `json:"-"`
|
||||
}
|
||||
|
||||
// textPartJSON contains the JSON metadata for the struct [TextPart]
|
||||
type textPartJSON struct {
|
||||
Text apijson.Field
|
||||
Type apijson.Field
|
||||
Synthetic apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
@@ -855,8 +859,9 @@ func (r TextPartType) IsKnown() bool {
|
||||
}
|
||||
|
||||
type TextPartParam struct {
|
||||
Text param.Field[string] `json:"text,required"`
|
||||
Type param.Field[TextPartType] `json:"type,required"`
|
||||
Text param.Field[string] `json:"text,required"`
|
||||
Type param.Field[TextPartType] `json:"type,required"`
|
||||
Synthetic param.Field[bool] `json:"synthetic"`
|
||||
}
|
||||
|
||||
func (r TextPartParam) MarshalJSON() (data []byte, err error) {
|
||||
@@ -1311,13 +1316,14 @@ func (r userMessageTimeJSON) RawJSON() string {
|
||||
}
|
||||
|
||||
type UserMessagePart struct {
|
||||
Type UserMessagePartType `json:"type,required"`
|
||||
Filename string `json:"filename"`
|
||||
Mime string `json:"mime"`
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
JSON userMessagePartJSON `json:"-"`
|
||||
union UserMessagePartUnion
|
||||
Type UserMessagePartType `json:"type,required"`
|
||||
Filename string `json:"filename"`
|
||||
Mime string `json:"mime"`
|
||||
Synthetic bool `json:"synthetic"`
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
JSON userMessagePartJSON `json:"-"`
|
||||
union UserMessagePartUnion
|
||||
}
|
||||
|
||||
// userMessagePartJSON contains the JSON metadata for the struct [UserMessagePart]
|
||||
@@ -1325,6 +1331,7 @@ type userMessagePartJSON struct {
|
||||
Type apijson.Field
|
||||
Filename apijson.Field
|
||||
Mime apijson.Field
|
||||
Synthetic apijson.Field
|
||||
Text apijson.Field
|
||||
URL apijson.Field
|
||||
raw string
|
||||
@@ -1390,11 +1397,12 @@ func (r UserMessagePartType) IsKnown() bool {
|
||||
}
|
||||
|
||||
type UserMessagePartParam struct {
|
||||
Type param.Field[UserMessagePartType] `json:"type,required"`
|
||||
Filename param.Field[string] `json:"filename"`
|
||||
Mime param.Field[string] `json:"mime"`
|
||||
Text param.Field[string] `json:"text"`
|
||||
URL param.Field[string] `json:"url"`
|
||||
Type param.Field[UserMessagePartType] `json:"type,required"`
|
||||
Filename param.Field[string] `json:"filename"`
|
||||
Mime param.Field[string] `json:"mime"`
|
||||
Synthetic param.Field[bool] `json:"synthetic"`
|
||||
Text param.Field[string] `json:"text"`
|
||||
URL param.Field[string] `json:"url"`
|
||||
}
|
||||
|
||||
func (r UserMessagePartParam) MarshalJSON() (data []byte, err error) {
|
||||
@@ -1409,6 +1417,7 @@ type UserMessagePartUnionParam interface {
|
||||
}
|
||||
|
||||
type SessionChatParams struct {
|
||||
Mode param.Field[string] `json:"mode,required"`
|
||||
ModelID param.Field[string] `json:"modelID,required"`
|
||||
Parts param.Field[[]UserMessagePartUnionParam] `json:"parts,required"`
|
||||
ProviderID param.Field[string] `json:"providerID,required"`
|
||||
|
||||
@@ -117,10 +117,12 @@ func TestSessionChat(t *testing.T) {
|
||||
context.TODO(),
|
||||
"id",
|
||||
opencode.SessionChatParams{
|
||||
Mode: opencode.F("mode"),
|
||||
ModelID: opencode.F("modelID"),
|
||||
Parts: opencode.F([]opencode.UserMessagePartUnionParam{opencode.TextPartParam{
|
||||
Text: opencode.F("text"),
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F("text"),
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Synthetic: opencode.F(true),
|
||||
}}),
|
||||
ProviderID: opencode.F("providerID"),
|
||||
},
|
||||
|
||||
@@ -62,11 +62,14 @@ export default defineConfig({
|
||||
sidebar: [
|
||||
"docs",
|
||||
"docs/cli",
|
||||
"docs/share",
|
||||
"docs/modes",
|
||||
"docs/rules",
|
||||
"docs/config",
|
||||
"docs/models",
|
||||
"docs/themes",
|
||||
"docs/keybinds",
|
||||
"docs/enterprise",
|
||||
"docs/mcp-servers",
|
||||
"docs/troubleshooting",
|
||||
],
|
||||
@@ -83,7 +86,7 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
redirects: {
|
||||
"/discord": "https://discord.com/invite/opencode",
|
||||
"/discord": "https://discord.gg/opencode",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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/" },
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
onMount,
|
||||
Suspense,
|
||||
onCleanup,
|
||||
createMemo,
|
||||
createSignal,
|
||||
SuspenseList,
|
||||
} from "solid-js"
|
||||
import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { IconArrowDown } from "./icons"
|
||||
@@ -277,7 +268,6 @@ export default function Share(props: {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(result.messages)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -327,42 +317,47 @@ export default function Share(props: {
|
||||
<div class={styles.parts}>
|
||||
<SuspenseList revealOrder="forwards">
|
||||
<For each={data().messages}>
|
||||
{(msg, msgIndex) => (
|
||||
<Suspense>
|
||||
<For
|
||||
each={msg.parts.filter((x, index) => {
|
||||
if (x.type === "step-start" && index > 0) return false
|
||||
if (x.type === "tool" && x.tool === "todoread") return false
|
||||
if (x.type === "text" && !x.text) return false
|
||||
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
|
||||
return false
|
||||
return true
|
||||
})}
|
||||
>
|
||||
{(part, partIndex) => {
|
||||
const last = createMemo(
|
||||
() => data().messages.length === msgIndex() + 1 && msg.parts.length === partIndex() + 1,
|
||||
)
|
||||
{(msg, msgIndex) => {
|
||||
const filteredParts = createMemo(() =>
|
||||
msg.parts.filter((x, index) => {
|
||||
if (x.type === "step-start" && index > 0) return false
|
||||
if (x.type === "tool" && x.tool === "todoread") return false
|
||||
if (x.type === "text" && !x.text) return false
|
||||
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
// Wait till all parts are loaded
|
||||
if (
|
||||
hash !== "" &&
|
||||
!hasScrolledToAnchor &&
|
||||
msg.parts.length === partIndex() + 1 &&
|
||||
data().messages.length === msgIndex() + 1
|
||||
) {
|
||||
hasScrolledToAnchor = true
|
||||
scrollToAnchor(hash)
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Suspense>
|
||||
<For each={filteredParts()}>
|
||||
{(part, partIndex) => {
|
||||
const last = createMemo(
|
||||
() =>
|
||||
data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1,
|
||||
)
|
||||
|
||||
return <Part last={last()} part={part} index={partIndex()} message={msg} />
|
||||
}}
|
||||
</For>
|
||||
</Suspense>
|
||||
)}
|
||||
onMount(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
// Wait till all parts are loaded
|
||||
if (
|
||||
hash !== "" &&
|
||||
!hasScrolledToAnchor &&
|
||||
filteredParts().length === partIndex() + 1 &&
|
||||
data().messages.length === msgIndex() + 1
|
||||
) {
|
||||
hasScrolledToAnchor = true
|
||||
scrollToAnchor(hash)
|
||||
}
|
||||
})
|
||||
|
||||
return <Part last={last()} part={part} index={partIndex()} message={msg} />
|
||||
}}
|
||||
</For>
|
||||
</Suspense>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SuspenseList>
|
||||
<div data-section="part" data-part-type="summary">
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.codeblock {
|
||||
pre {
|
||||
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
|
||||
background-color: var(--sl-color-bg-surface) !important;
|
||||
|
||||
span {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,6 @@ The opencode CLI takes the following flags.
|
||||
| `--help` | `-h` | Display help |
|
||||
| `--version` | | Print version number |
|
||||
| `--print-logs` | | Print logs to stderr |
|
||||
| `--prompt` | `-p` | Prompt to use |
|
||||
| `--model` | `-m` | Model to use in the form of provider/model |
|
||||
| `--mode` | | Mode to use |
|
||||
|
||||
@@ -3,12 +3,9 @@ title: Config
|
||||
description: Using the opencode JSON config.
|
||||
---
|
||||
|
||||
You can configure opencode using a JSON config file that can be placed in:
|
||||
You can configure opencode using a JSON config file.
|
||||
|
||||
- Globally under `~/.config/opencode/config.json`.
|
||||
- Your project root under `opencode.json`. This is safe to be checked into Git and uses the same schema as the global one.
|
||||
|
||||
```json
|
||||
```json title="opencode config"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"theme": "opencode",
|
||||
@@ -18,10 +15,24 @@ You can configure opencode using a JSON config file that can be placed in:
|
||||
}
|
||||
```
|
||||
|
||||
In most cases, you'll want to use the global config for things like themes, providers, or keybinds. Having a config per project is useful if you are using different providers for your company.
|
||||
This can be used to configure opencode globally or for a specific project.
|
||||
|
||||
---
|
||||
|
||||
### Global
|
||||
|
||||
Place your global opencode config in `~/.config/opencode/config.json`. You'll want to use the global config for things like themes, providers, or keybinds.
|
||||
|
||||
---
|
||||
|
||||
### Per project
|
||||
|
||||
You can also add a `opencode.json` in your project. This is useful for configuring providers or modes specific to your project.
|
||||
|
||||
When opencode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory.
|
||||
|
||||
This is also safe to be checked into Git and uses the same schema as the global one.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
@@ -32,6 +43,25 @@ Your editor should be able to validate and autocomplete based on the schema.
|
||||
|
||||
---
|
||||
|
||||
### Modes
|
||||
|
||||
opencode comes with two built-in modes: _build_, the default with all tools enabled. And _plan_, restricted mode with file modification tools disabled. You can override these built-in modes or define your own custom modes with the `mode` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mode": {
|
||||
"build": { },
|
||||
"plan": { },
|
||||
"my-custom-mode": { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more here](/docs/modes).
|
||||
|
||||
---
|
||||
|
||||
### Models
|
||||
|
||||
You can configure the providers and models you want to use in your opencode config through the `provider` and `model` options.
|
||||
@@ -44,7 +74,7 @@ You can configure the providers and models you want to use in your opencode conf
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more here](/docs/models).
|
||||
You can also configure [local models](/docs/models#local). [Learn more](/docs/models).
|
||||
|
||||
---
|
||||
|
||||
@@ -63,6 +93,36 @@ You can configure the theme you want to use in your opencode config through the
|
||||
|
||||
---
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
|
||||
- **macOS/Linux**: `~/.local/share/opencode/log/`
|
||||
- **Windows**: `%APPDATA%\opencode\log\`
|
||||
|
||||
You can configure the minimum log level through the `log_level` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"log_level": "INFO"
|
||||
}
|
||||
```
|
||||
|
||||
With the following options:
|
||||
|
||||
| Level | Description |
|
||||
| ------- | ---------------------------------------- |
|
||||
| `DEBUG` | All messages including debug information |
|
||||
| `INFO` | Informational messages and above |
|
||||
| `WARN` | Warnings and errors only |
|
||||
| `ERROR` | Errors only |
|
||||
|
||||
The **default** log level is `INFO`. If you are running opencode locally in
|
||||
development mode it's set to `DEBUG`.
|
||||
|
||||
---
|
||||
|
||||
### Keybinds
|
||||
|
||||
You can customize your keybinds through the `keybinds` option.
|
||||
@@ -93,28 +153,6 @@ You can configure MCP servers you want to use through the `mcp` option.
|
||||
|
||||
---
|
||||
|
||||
### Logging
|
||||
|
||||
You can configure the minimum log level through the `log_level` option. This controls which log messages are written to the log files.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"log_level": "INFO"
|
||||
}
|
||||
```
|
||||
|
||||
Available log levels are:
|
||||
|
||||
- `DEBUG` - All messages including debug information
|
||||
- `INFO` - Informational messages and above (default)
|
||||
- `WARN` - Warnings and errors only
|
||||
- `ERROR` - Errors only
|
||||
|
||||
The default log level is `INFO` in production and `DEBUG` in development mode.
|
||||
|
||||
---
|
||||
|
||||
### Disabled providers
|
||||
|
||||
You can disable providers that are loaded automatically through the `disabled_providers` option. This is useful when you want to prevent certain providers from being loaded even if their credentials are available.
|
||||
@@ -131,3 +169,58 @@ The `disabled_providers` option accepts an array of provider IDs. When a provide
|
||||
- It won't be loaded even if environment variables are set
|
||||
- It won't be loaded even if API keys are configured through `opencode auth login`
|
||||
- The provider's models won't appear in the model selection list
|
||||
|
||||
---
|
||||
|
||||
## Variables
|
||||
|
||||
You can use variable substitution in your config files to reference environment variables and file contents.
|
||||
|
||||
---
|
||||
|
||||
### Env vars
|
||||
|
||||
Use `{env:VARIABLE_NAME}` to substitute environment variables:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "{env:OPENCODE_MODEL}",
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"api_key": "{env:ANTHROPIC_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the environment variable is not set, it will be replaced with an empty string.
|
||||
|
||||
---
|
||||
|
||||
### Files
|
||||
|
||||
Use `{file:path/to/file}` to substitute the contents of a file:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["{file:./custom-instructions.md}"],
|
||||
"provider": {
|
||||
"openai": {
|
||||
"api_key": "{file:~/.secrets/openai-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
File paths can be:
|
||||
|
||||
- Relative to the config file directory
|
||||
- Or absolute paths starting with `/` or `~`
|
||||
|
||||
These are useful for:
|
||||
|
||||
- Keeping sensitive data like API keys in separate files.
|
||||
- Including large instruction files without cluttering your config.
|
||||
- Sharing common configuration snippets across multiple config files.
|
||||
|
||||
@@ -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.
|
||||
@@ -11,10 +11,14 @@ opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.
|
||||
|
||||
You can configure providers in your opencode config under the `provider` section.
|
||||
|
||||
---
|
||||
|
||||
### Defaults
|
||||
|
||||
Most popular providers are preloaded by default. If you've added the credentials for a provider through `opencode auth login`, they'll be available when you start opencode.
|
||||
|
||||
---
|
||||
|
||||
### Custom
|
||||
|
||||
You can add custom providers by specifying the npm package for the provider and the models you want to use.
|
||||
@@ -37,9 +41,44 @@ You can add custom providers by specifying the npm package for the provider and
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Local
|
||||
|
||||
To configure a local model, specify the npm package to use and the `baseURL`.
|
||||
You can configure local model like ones served through LM Studio or Ollama. To
|
||||
do so, you'll need to specify a couple of things.
|
||||
|
||||
Here's an example of configuring a local model from LM Studio:
|
||||
|
||||
```json title="opencode.json" {4-15}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"lmstudio": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "LM Studio (local)",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:1234/v1"
|
||||
},
|
||||
"models": {
|
||||
"google/gemma-3n-e4b": {
|
||||
"name": "Gemma 3n-e4b (local)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `lmstudio` is the custom provider ID. We'll use this later.
|
||||
- `npm` specifies the package to use for this provider. Here, `@ai-sdk/openai-compatible` is used for any OpenAI-compatible API.
|
||||
- `name` is the display name for the provider in the UI.
|
||||
- `options.baseURL` is the endpoint for the local server.
|
||||
- `models` is a map of model IDs to their configurations. The model name will be displayed in the model selection list.
|
||||
|
||||
Similarly, to configure a local model from Ollama:
|
||||
|
||||
```json title="opencode.json" {5,7}
|
||||
{
|
||||
@@ -58,6 +97,18 @@ To configure a local model, specify the npm package to use and the `baseURL`.
|
||||
}
|
||||
```
|
||||
|
||||
To set one of these as the default model, you can set the `model` key at the
|
||||
root.
|
||||
|
||||
```json title="opencode.json" {3}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "lmstudio/google/gemma-3n-e4b"
|
||||
}
|
||||
```
|
||||
|
||||
Here the full ID is `provider_id/model_id`, where `provider_id` is the key in the `provider` list we set above and `model_id` is the key from the `provider.models` list.
|
||||
|
||||
---
|
||||
|
||||
## Select a model
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Modes
|
||||
description: Different modes for different use cases.
|
||||
---
|
||||
|
||||
Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases.
|
||||
|
||||
It comes with two built-in modes: **build** and **plan**. You can customize
|
||||
these or configure your own through the opencode config.
|
||||
|
||||
:::tip
|
||||
Use the plan mode to analyze code and review suggestions without making any code
|
||||
changes.
|
||||
:::
|
||||
|
||||
You can switch between modes during a session or configure them in your config file.
|
||||
|
||||
---
|
||||
|
||||
## Built-in
|
||||
|
||||
opencode comes with two built-in modes.
|
||||
|
||||
---
|
||||
|
||||
### Build
|
||||
|
||||
Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands.
|
||||
|
||||
---
|
||||
|
||||
### Plan
|
||||
|
||||
A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default:
|
||||
|
||||
- `write` - Cannot create new files
|
||||
- `edit` - Cannot modify existing files
|
||||
- `patch` - Cannot apply patches
|
||||
- `bash` - Cannot execute shell commands
|
||||
|
||||
This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase.
|
||||
|
||||
---
|
||||
|
||||
## Switching
|
||||
|
||||
You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind.
|
||||
|
||||
---
|
||||
|
||||
## Configure
|
||||
|
||||
You can customize the built-in modes or create your own in the opencode [config](/docs/config).
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mode": {
|
||||
"build": {
|
||||
"model": "anthropic/claude-sonnet-4-20250514",
|
||||
"prompt": "{file:./prompts/build.txt}",
|
||||
"tools": {
|
||||
"write": true,
|
||||
"edit": true,
|
||||
"bash": true
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"model": "anthropic/claude-haiku-4-20250514",
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"bash": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let's look at these options in detail.
|
||||
|
||||
---
|
||||
|
||||
### Model
|
||||
|
||||
Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"mode": {
|
||||
"plan": {
|
||||
"model": "anthropic/claude-haiku-4-20250514"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Prompt
|
||||
|
||||
Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"mode": {
|
||||
"review": {
|
||||
"prompt": "{file:./prompts/code-review.txt}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This path is relative to where the config file is located. So this works for
|
||||
both the global opencode config and the project specific config.
|
||||
|
||||
---
|
||||
|
||||
### Tools
|
||||
|
||||
Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`.
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": {
|
||||
"readonly": {
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"bash": false,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no tools are specified, all tools are enabled by default.
|
||||
|
||||
---
|
||||
|
||||
#### Available tools
|
||||
|
||||
Here are all the tools can be controlled through the mode config.
|
||||
|
||||
| Tool | Description |
|
||||
| ----------- | ----------------------- |
|
||||
| `bash` | Execute shell commands |
|
||||
| `edit` | Modify existing files |
|
||||
| `write` | Create new files |
|
||||
| `read` | Read file contents |
|
||||
| `grep` | Search file contents |
|
||||
| `glob` | Find files by pattern |
|
||||
| `list` | List directory contents |
|
||||
| `patch` | Apply patches to files |
|
||||
| `todowrite` | Manage todo lists |
|
||||
| `todoread` | Read todo lists |
|
||||
| `webfetch` | Fetch web content |
|
||||
|
||||
---
|
||||
|
||||
## Custom modes
|
||||
|
||||
You can create your own custom modes by adding them to the `mode` configuration. For example, a documentation mode that focuses on reading and analysis.
|
||||
|
||||
```json title="opencode.json" {4-14}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mode": {
|
||||
"docs": {
|
||||
"prompt": "{file:./prompts/documentation.txt}",
|
||||
"tools": {
|
||||
"write": true,
|
||||
"edit": true,
|
||||
"bash": false,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Use cases
|
||||
|
||||
Here are some common use cases for different modes.
|
||||
|
||||
- **Build mode**: Full development work with all tools enabled
|
||||
- **Plan mode**: Analysis and planning without making changes
|
||||
- **Review mode**: Code review with read-only access plus documentation tools
|
||||
- **Debug mode**: Focused on investigation with bash and read tools enabled
|
||||
- **Docs mode**: Documentation writing with file operations but no system commands
|
||||
|
||||
You might also find different models are good for different use cases.
|
||||
@@ -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.
|
||||
@@ -3,9 +3,14 @@ title: Troubleshooting
|
||||
description: Common issues and how to resolve them.
|
||||
---
|
||||
|
||||
## Logs
|
||||
To debug any issues with opencode, you can check the logs or the session data
|
||||
that it stores locally.
|
||||
|
||||
opencode writes detailed logs that can help diagnose issues. Log files are stored in:
|
||||
---
|
||||
|
||||
### Logs
|
||||
|
||||
Log files are written to:
|
||||
|
||||
- **macOS/Linux**: `~/.local/share/opencode/log/`
|
||||
- **Windows**: `%APPDATA%\opencode\log\`
|
||||
@@ -16,7 +21,7 @@ You can configure the log level in your [config file](/docs/config#logging) to g
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
### Storage
|
||||
|
||||
opencode stores session data and other application data on disk at:
|
||||
|
||||
@@ -25,9 +30,11 @@ opencode stores session data and other application data on disk at:
|
||||
|
||||
This directory contains:
|
||||
|
||||
- `auth.json` - Authentication data (e.g., API keys, OAuth tokens)
|
||||
- `auth.json` - Authentication data like API keys, OAuth tokens
|
||||
- `log/` - Application logs
|
||||
- `project/` - Project-specific data (e.g., session and message data)
|
||||
- `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/`
|
||||
|
||||
---
|
||||
|
||||
@@ -35,40 +42,77 @@ This directory contains:
|
||||
|
||||
If you're experiencing issues with opencode:
|
||||
|
||||
### Report issues on GitHub
|
||||
1. **Report issues on GitHub**
|
||||
|
||||
The best way to report bugs or request features is through our GitHub repository:
|
||||
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)
|
||||
[**github.com/sst/opencode/issues**](https://github.com/sst/opencode/issues)
|
||||
|
||||
Before creating a new issue, please search existing issues to see if your problem has already been reported.
|
||||
Before creating a new issue, search existing issues to see if your problem has already been reported.
|
||||
|
||||
### Join our Discord
|
||||
2. **Join our Discord**
|
||||
|
||||
For real-time help and community discussion, join our Discord server:
|
||||
For real-time help and community discussion, join our Discord server:
|
||||
|
||||
[**opencode.ai/discord**](https://opencode.ai/discord)
|
||||
|
||||
Our community and team are happy to help with questions and troubleshooting.
|
||||
[**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
|
||||
|
||||
---
|
||||
|
||||
### Copy/paste not working on Linux
|
||||
|
||||
Linux users need to have one of the following clipboard utilities installed for copy/paste functionality to work:
|
||||
|
||||
**For X11 systems:**
|
||||
|
||||
```bash
|
||||
apt install -y xclip
|
||||
# or
|
||||
apt install -y xsel
|
||||
```
|
||||
|
||||
**For Wayland systems:**
|
||||
|
||||
```bash
|
||||
apt install -y wl-clipboard
|
||||
```
|
||||
|
||||
**For headless environments:**
|
||||
|
||||
```bash
|
||||
apt install -y xvfb
|
||||
# and run:
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0
|
||||
```
|
||||
|
||||
opencode will automatically detect and use the first available clipboard tool in order of preference: `xclip`, `xsel`, then `wl-clipboard`.
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ SERVER_PID=$!
|
||||
echo "Waiting for server to start..."
|
||||
sleep 3
|
||||
|
||||
echo "Fetching OpenAPI spec from http://localhost:4096/doc..."
|
||||
curl -s http://localhost:4096/doc > openapi.json
|
||||
echo "Fetching OpenAPI spec from http://127.0.0.1:4096/doc..."
|
||||
curl -s http://127.0.0.1:4096/doc > openapi.json
|
||||
|
||||
echo "Stopping server..."
|
||||
kill $SERVER_PID
|
||||
|
||||
@@ -60,6 +60,7 @@ async function fetchReleases(): Promise<Release[]> {
|
||||
|
||||
if (batch.length < per) break
|
||||
page++
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
return releases
|
||||
|
||||
@@ -49,10 +49,12 @@ resources:
|
||||
models:
|
||||
app: App
|
||||
logLevel: LogLevel
|
||||
mode: Mode
|
||||
methods:
|
||||
get: get /app
|
||||
init: post /app/init
|
||||
log: post /log
|
||||
modes: get /mode
|
||||
|
||||
find:
|
||||
models:
|
||||
|
||||
Reference in New Issue
Block a user