mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c056b0add9 | |||
| b00bb3c083 | |||
| d9befd3aa6 | |||
| 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 |
@@ -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) |
|
||||
|
||||
@@ -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(" ")
|
||||
@@ -61,7 +66,9 @@ export const RunCommand = cmd({
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
const list = Session.list()
|
||||
const first = await list.next()
|
||||
await list.return()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
@@ -139,10 +146,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()
|
||||
|
||||
@@ -70,6 +70,7 @@ export namespace Config {
|
||||
.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"),
|
||||
@@ -194,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({
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace FileWatcher {
|
||||
)
|
||||
|
||||
export function init() {
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER) return
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER || true) return
|
||||
state()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,6 @@ export namespace LSPClient {
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) 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,33 +61,16 @@ export namespace LSPServer {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
// tsserver sucks and won't start processing codebase until you open a file
|
||||
onInitialized: async (lsp) => {
|
||||
const [hint] = await Ripgrep.files({
|
||||
cwd: lsp.root,
|
||||
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
|
||||
limit: 1,
|
||||
})
|
||||
const wait = new Promise<void>(async (resolve) => {
|
||||
const notif = lsp.connection.onNotification("$/progress", (params) => {
|
||||
if (params.value.kind !== "end") return
|
||||
notif.dispose()
|
||||
resolve()
|
||||
})
|
||||
await lsp.notify.open({ path: path.join(lsp.root, hint) })
|
||||
})
|
||||
await withTimeout(wait, 5_000)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
roots: async (app) => {
|
||||
const work = await SimpleRoots(["go.work"])(app)
|
||||
if (work.length > 0) return work
|
||||
return SimpleRoots(["go.mod", "go.sum"])(app)
|
||||
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) {
|
||||
@@ -125,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", {
|
||||
@@ -166,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,
|
||||
@@ -191,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) {
|
||||
@@ -246,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,
|
||||
|
||||
@@ -448,6 +448,7 @@ export namespace Server {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
mode: z.string(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -36,10 +36,14 @@ 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"),
|
||||
@@ -285,7 +289,6 @@ export namespace Session {
|
||||
mode?: string
|
||||
parts: MessageV2.UserPart[]
|
||||
}) {
|
||||
using abort = lock(input.sessionID)
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
|
||||
@@ -318,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,
|
||||
@@ -336,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)
|
||||
|
||||
@@ -346,35 +349,74 @@ 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",
|
||||
synthetic: true,
|
||||
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -540,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,
|
||||
@@ -679,6 +721,11 @@ export namespace Session {
|
||||
const usage = getUsage(model.info, value.usage, value.providerMetadata)
|
||||
next.cost += usage.cost
|
||||
next.tokens = usage.tokens
|
||||
next.parts.push({
|
||||
type: "step-finish",
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
})
|
||||
break
|
||||
|
||||
case "text-start":
|
||||
|
||||
@@ -116,6 +116,25 @@ export namespace MessageV2 {
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const StepFinishPart = z
|
||||
.object({
|
||||
type: z.literal("step-finish"),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "StepFinishPart",
|
||||
})
|
||||
export type StepFinishPart = z.infer<typeof StepFinishPart>
|
||||
|
||||
const Base = z.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
@@ -137,9 +156,11 @@ export namespace MessageV2 {
|
||||
})
|
||||
export type User = z.infer<typeof User>
|
||||
|
||||
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export const AssistantPart = z
|
||||
.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart, StepFinishPart])
|
||||
.openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export type AssistantPart = z.infer<typeof AssistantPart>
|
||||
|
||||
export const Assistant = Base.extend({
|
||||
@@ -164,8 +185,8 @@ export namespace MessageV2 {
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
|
||||
@@ -30,6 +30,7 @@ export namespace Mode {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
bash: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -136,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)
|
||||
@@ -152,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
|
||||
}
|
||||
|
||||
@@ -163,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 {
|
||||
@@ -179,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
|
||||
}
|
||||
}
|
||||
@@ -223,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).
|
||||
@@ -384,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
|
||||
}
|
||||
@@ -400,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().
|
||||
@@ -455,6 +480,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
ta = updateTextareaStyles(ta)
|
||||
|
||||
m := &editorComponent{
|
||||
app: app,
|
||||
@@ -462,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-eb25bb3673f94d0e98a7036e2a2b0ed7ad63d1598665f2d5e091ec0835273798.yml
|
||||
openapi_spec_hash: 62f6a8a06aaa4f4ae13e85d56652724f
|
||||
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
|
||||
|
||||
+142
-28
@@ -438,12 +438,17 @@ func (r AssistantMessageErrorName) IsKnown() bool {
|
||||
type AssistantMessagePart struct {
|
||||
Type AssistantMessagePartType `json:"type,required"`
|
||||
ID string `json:"id"`
|
||||
Cost float64 `json:"cost"`
|
||||
// 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"`
|
||||
// This field can have the runtime type of
|
||||
// [AssistantMessagePartStepFinishPartTokens].
|
||||
Tokens interface{} `json:"tokens"`
|
||||
Tool string `json:"tool"`
|
||||
JSON assistantMessagePartJSON `json:"-"`
|
||||
union AssistantMessagePartUnion
|
||||
}
|
||||
|
||||
// assistantMessagePartJSON contains the JSON metadata for the struct
|
||||
@@ -451,8 +456,11 @@ type AssistantMessagePart struct {
|
||||
type assistantMessagePartJSON struct {
|
||||
Type apijson.Field
|
||||
ID apijson.Field
|
||||
Cost apijson.Field
|
||||
State apijson.Field
|
||||
Synthetic apijson.Field
|
||||
Text apijson.Field
|
||||
Tokens apijson.Field
|
||||
Tool apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
@@ -474,12 +482,14 @@ func (r *AssistantMessagePart) UnmarshalJSON(data []byte) (err error) {
|
||||
// AsUnion returns a [AssistantMessagePartUnion] interface which you can cast to
|
||||
// the specific types for more type safety.
|
||||
//
|
||||
// Possible runtime types of the union are [TextPart], [ToolPart], [StepStartPart].
|
||||
// Possible runtime types of the union are [TextPart], [ToolPart], [StepStartPart],
|
||||
// [AssistantMessagePartStepFinishPart].
|
||||
func (r AssistantMessagePart) AsUnion() AssistantMessagePartUnion {
|
||||
return r.union
|
||||
}
|
||||
|
||||
// Union satisfied by [TextPart], [ToolPart] or [StepStartPart].
|
||||
// Union satisfied by [TextPart], [ToolPart], [StepStartPart] or
|
||||
// [AssistantMessagePartStepFinishPart].
|
||||
type AssistantMessagePartUnion interface {
|
||||
implementsAssistantMessagePart()
|
||||
}
|
||||
@@ -503,20 +513,117 @@ func init() {
|
||||
Type: reflect.TypeOf(StepStartPart{}),
|
||||
DiscriminatorValue: "step-start",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(AssistantMessagePartStepFinishPart{}),
|
||||
DiscriminatorValue: "step-finish",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type AssistantMessagePartStepFinishPart struct {
|
||||
Cost float64 `json:"cost,required"`
|
||||
Tokens AssistantMessagePartStepFinishPartTokens `json:"tokens,required"`
|
||||
Type AssistantMessagePartStepFinishPartType `json:"type,required"`
|
||||
JSON assistantMessagePartStepFinishPartJSON `json:"-"`
|
||||
}
|
||||
|
||||
// assistantMessagePartStepFinishPartJSON contains the JSON metadata for the struct
|
||||
// [AssistantMessagePartStepFinishPart]
|
||||
type assistantMessagePartStepFinishPartJSON struct {
|
||||
Cost apijson.Field
|
||||
Tokens apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *AssistantMessagePartStepFinishPart) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r assistantMessagePartStepFinishPartJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r AssistantMessagePartStepFinishPart) implementsAssistantMessagePart() {}
|
||||
|
||||
type AssistantMessagePartStepFinishPartTokens struct {
|
||||
Cache AssistantMessagePartStepFinishPartTokensCache `json:"cache,required"`
|
||||
Input float64 `json:"input,required"`
|
||||
Output float64 `json:"output,required"`
|
||||
Reasoning float64 `json:"reasoning,required"`
|
||||
JSON assistantMessagePartStepFinishPartTokensJSON `json:"-"`
|
||||
}
|
||||
|
||||
// assistantMessagePartStepFinishPartTokensJSON contains the JSON metadata for the
|
||||
// struct [AssistantMessagePartStepFinishPartTokens]
|
||||
type assistantMessagePartStepFinishPartTokensJSON struct {
|
||||
Cache apijson.Field
|
||||
Input apijson.Field
|
||||
Output apijson.Field
|
||||
Reasoning apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *AssistantMessagePartStepFinishPartTokens) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r assistantMessagePartStepFinishPartTokensJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type AssistantMessagePartStepFinishPartTokensCache struct {
|
||||
Read float64 `json:"read,required"`
|
||||
Write float64 `json:"write,required"`
|
||||
JSON assistantMessagePartStepFinishPartTokensCacheJSON `json:"-"`
|
||||
}
|
||||
|
||||
// assistantMessagePartStepFinishPartTokensCacheJSON contains the JSON metadata for
|
||||
// the struct [AssistantMessagePartStepFinishPartTokensCache]
|
||||
type assistantMessagePartStepFinishPartTokensCacheJSON struct {
|
||||
Read apijson.Field
|
||||
Write apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *AssistantMessagePartStepFinishPartTokensCache) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r assistantMessagePartStepFinishPartTokensCacheJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type AssistantMessagePartStepFinishPartType string
|
||||
|
||||
const (
|
||||
AssistantMessagePartStepFinishPartTypeStepFinish AssistantMessagePartStepFinishPartType = "step-finish"
|
||||
)
|
||||
|
||||
func (r AssistantMessagePartStepFinishPartType) IsKnown() bool {
|
||||
switch r {
|
||||
case AssistantMessagePartStepFinishPartTypeStepFinish:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AssistantMessagePartType string
|
||||
|
||||
const (
|
||||
AssistantMessagePartTypeText AssistantMessagePartType = "text"
|
||||
AssistantMessagePartTypeTool AssistantMessagePartType = "tool"
|
||||
AssistantMessagePartTypeStepStart AssistantMessagePartType = "step-start"
|
||||
AssistantMessagePartTypeText AssistantMessagePartType = "text"
|
||||
AssistantMessagePartTypeTool AssistantMessagePartType = "tool"
|
||||
AssistantMessagePartTypeStepStart AssistantMessagePartType = "step-start"
|
||||
AssistantMessagePartTypeStepFinish AssistantMessagePartType = "step-finish"
|
||||
)
|
||||
|
||||
func (r AssistantMessagePartType) IsKnown() bool {
|
||||
switch r {
|
||||
case AssistantMessagePartTypeText, AssistantMessagePartTypeTool, AssistantMessagePartTypeStepStart:
|
||||
case AssistantMessagePartTypeText, AssistantMessagePartTypeTool, AssistantMessagePartTypeStepStart, AssistantMessagePartTypeStepFinish:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -815,15 +922,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 +964,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 +1421,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 +1436,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 +1502,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 +1522,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,8 +62,9 @@ export default defineConfig({
|
||||
sidebar: [
|
||||
"docs",
|
||||
"docs/cli",
|
||||
"docs/rules",
|
||||
"docs/share",
|
||||
"docs/modes",
|
||||
"docs/rules",
|
||||
"docs/config",
|
||||
"docs/models",
|
||||
"docs/themes",
|
||||
|
||||
@@ -129,3 +129,4 @@ The opencode CLI takes the following flags.
|
||||
| `--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).
|
||||
|
||||
---
|
||||
|
||||
@@ -81,12 +111,12 @@ You can configure the minimum log level through the `log_level` option.
|
||||
|
||||
With the following options:
|
||||
|
||||
| Level | Description |
|
||||
| ----- | ----------- |
|
||||
| Level | Description |
|
||||
| ------- | ---------------------------------------- |
|
||||
| `DEBUG` | All messages including debug information |
|
||||
| `INFO` | Informational messages and above |
|
||||
| `WARN` | Warnings and errors only |
|
||||
| `ERROR` | Errors only |
|
||||
| `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`.
|
||||
@@ -139,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -43,17 +43,17 @@ This directory contains:
|
||||
If you're experiencing issues with opencode:
|
||||
|
||||
1. **Report issues on GitHub**
|
||||
|
||||
|
||||
The best way to report bugs or request features is through our GitHub repository:
|
||||
|
||||
|
||||
[**github.com/sst/opencode/issues**](https://github.com/sst/opencode/issues)
|
||||
|
||||
|
||||
Before creating a new issue, search existing issues to see if your problem has already been reported.
|
||||
|
||||
2. **Join our Discord**
|
||||
|
||||
For real-time help and community discussion, join our Discord server:
|
||||
|
||||
|
||||
[**opencode.ai/discord**](https://opencode.ai/discord)
|
||||
|
||||
---
|
||||
@@ -85,3 +85,34 @@ Here are some common issues and how to resolve them.
|
||||
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