Compare commits

...

26 Commits

Author SHA1 Message Date
Dax Raad a11999137f disable snapshots 2025-07-24 11:08:20 -04:00
Aiden Cline a16554d445 fix: slog error log serialization (#1276) 2025-07-24 07:19:00 -05:00
danielfyhr 2553137395 add aura theme (#1280) 2025-07-24 07:17:27 -05:00
GitHub Action 6b6b81556f ignore: update download stats 2025-07-24 2025-07-24 12:04:18 +00:00
Dax Raad ff23f67ad5 disable undo/redo for now 2025-07-23 21:02:13 -04:00
Rico Sta. Cruz 8f0644e35b fix: update max visible height in list tests (#1269) 2025-07-23 20:49:15 -04:00
Dax Raad 3fdd23df16 fix header width 2025-07-23 20:48:35 -04:00
Dax Raad 2c82ee592c wip: always force create snapshot 2025-07-23 20:46:43 -04:00
Dax Raad 1ad529db59 wip: fix redoing 2025-07-23 20:42:02 -04:00
Dax 96866e52ce basic undo feature (#1268)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Andrew Joslin <andrew@ajoslin.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Tobias Walle <9933601+tobias-walle@users.noreply.github.com>
2025-07-23 20:30:46 -04:00
Yihui Khuu 507c975e92 feat: pass mode into task tool (#1248) 2025-07-23 20:29:59 -04:00
Aiden Cline 3e69d5276b docs: remove deprecated 'log_level' reference in docs (#1258) 2025-07-23 18:53:58 -04:00
Aiden Cline 289a4d9b18 tweak: handle pasted attachment references (#1257) 2025-07-23 15:41:17 -05:00
Tobias Walle 12bf5f641d fix "working" spinner animation (#1054) (#1259) 2025-07-23 15:40:34 -05:00
Dax Raad 2051e85e96 remove providers path 2025-07-23 12:15:31 -04:00
Dax Raad 12b86829d9 add debug paths command 2025-07-23 12:14:54 -04:00
GitHub Action 6c9ec54129 ignore: update download stats 2025-07-23 2025-07-23 12:04:18 +00:00
Aiden Cline b7b0cdbd7c tweak: ensure most recently interacted with session appears at the top (#1239) 2025-07-22 22:37:36 -05:00
Dax Raad fd98c3189a config: improve config schema 2025-07-22 20:35:40 -04:00
Jay V 1278353616 docs: edit ide 2025-07-22 19:02:30 -04:00
Andrew Joslin 638ec7bc50 Allow multiline prompts for github agent (#1225) 2025-07-22 18:30:51 -04:00
Aiden Cline 38ae7d60aa feat(tui): support pipe into tui (#1230) 2025-07-22 17:19:20 -05:00
Jay V 2d1f9fc321 docs: add tutorial closes #740 2025-07-22 17:54:53 -04:00
Frank ee0c8132db wip: vscode extension 2025-07-22 17:13:58 -04:00
Dax Raad c2208fa1f9 ci: error github api fail 2025-07-22 17:06:06 -04:00
Frank bf42d8b011 wip: vscode extension 2025-07-22 16:50:56 -04:00
42 changed files with 1183 additions and 238 deletions
+2
View File
@@ -24,3 +24,5 @@
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
+4 -1
View File
@@ -90,7 +90,10 @@ if (!snapshot) {
}
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data) => data.tag_name)
console.log("finding commits between", previous, "and", "HEAD")
+2
View File
@@ -3,6 +3,7 @@ import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
@@ -10,6 +11,7 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
Format.init()
ConfigHooks.init()
LSP.init()
Snapshot.init()
return cb(app)
})
@@ -1,3 +1,4 @@
import { Global } from "../../../global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
@@ -15,6 +16,7 @@ export const DebugCommand = cmd({
.command(FileCommand)
.command(ScrapCommand)
.command(SnapshotCommand)
.command(PathsCommand)
.command({
command: "wait",
async handler() {
@@ -26,3 +28,12 @@ export const DebugCommand = cmd({
.demandCommand(),
async handler() {},
})
const PathsCommand = cmd({
command: "paths",
handler() {
for (const [key, value] of Object.entries(Global.Path)) {
console.log(key.padEnd(10), value)
}
},
})
@@ -1,10 +1,12 @@
import { Session } from "../../../session"
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) => yargs.command(CreateCommand).command(RestoreCommand).command(DiffCommand).demandCommand(),
builder: (yargs) =>
yargs.command(CreateCommand).command(RestoreCommand).command(DiffCommand).command(RevertCommand).demandCommand(),
async handler() {},
})
@@ -12,7 +14,7 @@ const CreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
const result = await Snapshot.create()
console.log(result)
})
},
@@ -28,7 +30,7 @@ const RestoreCommand = cmd({
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
await Snapshot.restore(args.commit)
console.log("restored")
})
},
@@ -45,8 +47,34 @@ export const DiffCommand = cmd({
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const diff = await Snapshot.diff("test", args.commit)
const diff = await Snapshot.diff(args.commit)
console.log(diff)
})
},
})
export const RevertCommand = cmd({
command: "revert <sessionID> <messageID>",
describe: "revert",
builder: (yargs) =>
yargs
.positional("sessionID", {
type: "string",
description: "sessionID",
demandOption: true,
})
.positional("messageID", {
type: "string",
description: "messageID",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await Session.revert({
sessionID: args.sessionID,
messageID: args.messageID,
})
console.log(session?.revert)
})
},
})
+19 -6
View File
@@ -26,6 +26,9 @@ export namespace Config {
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
if (!result.username) {
const os = await import("os")
@@ -89,7 +92,7 @@ export namespace Config {
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
@@ -118,7 +121,9 @@ export namespace Config {
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
})
.strict()
@@ -169,10 +174,18 @@ export namespace Config {
.describe("Modes configuration, see https://opencode.ai/docs/modes"),
provider: z
.record(
ModelsDev.Provider.partial().extend({
models: z.record(ModelsDev.Model.partial()),
options: z.record(z.any()).optional(),
}),
ModelsDev.Provider.partial()
.extend({
models: z.record(ModelsDev.Model.partial()),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
})
.catchall(z.any())
.optional(),
})
.strict(),
)
.optional()
.describe("Custom provider configurations and model overrides"),
-2
View File
@@ -13,7 +13,6 @@ export namespace Global {
export const Path = {
data,
bin: path.join(data, "bin"),
providers: path.join(config, "providers"),
cache,
config,
state,
@@ -23,7 +22,6 @@ export namespace Global {
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])
+69 -7
View File
@@ -58,15 +58,20 @@ export namespace Server {
})
})
.use(async (c, next) => {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
const skipLogging = c.req.path === "/log"
if (!skipLogging) {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
}
const start = Date.now()
await next()
log.info("response", {
duration: Date.now() - start,
})
if (!skipLogging) {
log.info("response", {
duration: Date.now() - start,
})
}
})
.get(
"/doc",
@@ -196,6 +201,7 @@ export namespace Server {
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list())
sessions.sort((a, b) => b.time.updated - a.time.updated)
return c.json(sessions)
},
)
@@ -460,6 +466,62 @@ export namespace Server {
return c.json(msg)
},
)
.post(
"/session/:id/revert",
describeRoute({
description: "Revert a message",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
zValidator("json", Session.RevertInput.omit({ sessionID: true })),
async (c) => {
const id = c.req.valid("param").id
log.info("revert", c.req.valid("json"))
const session = await Session.revert({ sessionID: id, ...c.req.valid("json") })
return c.json(session)
},
)
.post(
"/session/:id/unrevert",
describeRoute({
description: "Restore all reverted messages",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const session = await Session.unrevert({ sessionID: id })
return c.json(session)
},
)
.get(
"/config/providers",
describeRoute({
+75 -67
View File
@@ -40,6 +40,7 @@ import { MessageV2 } from "./message-v2"
import { Mode } from "./mode"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { splitWhen } from "remeda"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -64,7 +65,7 @@ export namespace Session {
revert: z
.object({
messageID: z.string(),
part: z.number(),
partID: z.string().optional(),
snapshot: z.string().optional(),
})
.optional(),
@@ -246,7 +247,7 @@ export namespace Session {
const read = await Storage.readJSON<MessageV2.Info>(p)
result.push({
info: read,
parts: await parts(sessionID, read.id),
parts: await getParts(sessionID, read.id),
})
}
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
@@ -257,7 +258,7 @@ export namespace Session {
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
}
export async function parts(sessionID: string, messageID: string) {
export async function getParts(sessionID: string, messageID: string) {
const result = [] as MessageV2.Part[]
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
const read = await Storage.readJSON<MessageV2.Part>(item)
@@ -370,6 +371,7 @@ export namespace Session {
const l = log.clone().tag("session", input.sessionID)
l.info("chatting")
const inputMode = input.mode ?? "build"
const userMsg: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -494,7 +496,7 @@ export namespace Session {
]
}),
).then((x) => x.flat())
if (input.mode === "plan")
if (inputMode === "plan")
userParts.push({
id: Identifier.ascending("part"),
messageID: userMsg.id,
@@ -508,6 +510,8 @@ export namespace Session {
for (const part of userParts) {
await updatePart(part)
}
// mark session as updated since a message has been added to it
await update(input.sessionID, (_draft) => {})
if (isLocked(input.sessionID)) {
return new Promise((resolve) => {
@@ -528,30 +532,26 @@ export namespace Session {
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.info.id > session.revert.messageID ||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
await Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.info.id,
})
continue
}
if (msg.info.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
const messageID = session.revert.messageID
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
msgs = preserve
for (const msg of remove) {
await Storage.remove(`session/message/${input.sessionID}/${msg.info.id}`)
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id })
}
const last = preserve.at(-1)
if (session.revert.partID && last) {
const partID = session.revert.partID
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
last.parts = preserveParts
for (const part of removeParts) {
await Storage.remove(`session/part/${input.sessionID}/${last.info.id}/${part.id}`)
await Bus.publish(MessageV2.Event.PartRemoved, {
messageID: last.info.id,
partID: part.id,
})
}
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
@@ -615,7 +615,7 @@ export namespace Session {
.catch(() => {})
}
const mode = await Mode.get(input.mode ?? "build")
const mode = await Mode.get(inputMode)
let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : []
system.push(...(mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.modelID)))
system.push(...(await SystemPrompt.environment()))
@@ -628,6 +628,7 @@ export namespace Session {
id: Identifier.ascending("message"),
role: "assistant",
system,
mode: inputMode,
path: {
cwd: app.path.cwd,
root: app.path.root,
@@ -827,7 +828,7 @@ export namespace Session {
})
switch (value.type) {
case "start":
const snapshot = await Snapshot.create(assistantMsg.sessionID)
const snapshot = await Snapshot.create()
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
@@ -891,7 +892,7 @@ export namespace Session {
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
const snapshot = await Snapshot.create()
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
@@ -920,7 +921,7 @@ export namespace Session {
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
const snapshot = await Snapshot.create()
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
@@ -1039,7 +1040,7 @@ export namespace Session {
error: assistantMsg.error,
})
}
const p = await parts(assistantMsg.sessionID, assistantMsg.id)
const p = await getParts(assistantMsg.sessionID, assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed") {
updatePart({
@@ -1063,47 +1064,53 @@ export namespace Session {
}
}
export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
// TODO
/*
const message = await getMessage(input.sessionID, input.messageID)
if (!message) return
const part = message.parts[input.part]
if (!part) return
export const RevertInput = z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part").optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
export async function revert(input: RevertInput) {
const all = await messages(input.sessionID)
const session = await get(input.sessionID)
const snapshot =
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
const old = (() => {
if (message.role === "assistant") {
const lastTool = message.parts.findLast(
(part, index) =>
part.type === "tool-invocation" && index < input.part,
)
if (lastTool && lastTool.type === "tool-invocation")
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
.snapshot
let lastUser: MessageV2.User | undefined
let lastSnapshot: MessageV2.SnapshotPart | undefined
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (part.type === "snapshot") lastSnapshot = part
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
// if no useful parts left in message, same as reverting whole message
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
const snapshot = session.revert?.snapshot ?? (await Snapshot.create())
log.info("revert snapshot", { snapshot })
if (lastSnapshot) await Snapshot.restore(lastSnapshot.snapshot)
const next = await update(input.sessionID, (draft) => {
draft.revert = {
// if not part id jump to the last user message
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
snapshot,
}
})
return next
}
remaining.push(part)
}
return message.metadata.snapshot
})()
if (old) await Snapshot.restore(input.sessionID, old)
await update(input.sessionID, (draft) => {
draft.revert = {
messageID: input.messageID,
part: input.part,
snapshot,
}
})
*/
}
}
export async function unrevert(sessionID: string) {
const session = await get(sessionID)
if (!session) return
if (!session.revert) return
if (session.revert.snapshot) await Snapshot.restore(sessionID, session.revert.snapshot)
update(sessionID, (draft) => {
export async function unrevert(input: { sessionID: string }) {
log.info("unreverting", input)
const session = await get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
const next = await update(input.sessionID, (draft) => {
draft.revert = undefined
})
return next
}
export async function summarize(input: { sessionID: string; providerID: string; modelID: string }) {
@@ -1120,6 +1127,7 @@ export namespace Session {
role: "assistant",
sessionID: input.sessionID,
system,
mode: "build",
path: {
cwd: app.path.cwd,
root: app.path.root,
@@ -226,6 +226,7 @@ export namespace MessageV2 {
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
mode: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
@@ -271,6 +272,13 @@ export namespace MessageV2 {
part: Part,
}),
),
PartRemoved: Bus.event(
"message.part.removed",
z.object({
messageID: z.string(),
partID: z.string(),
}),
),
}
export function fromV1(v1: Message.Info) {
@@ -290,6 +298,7 @@ export namespace MessageV2 {
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
system: v1.metadata.assistant!.system,
mode: "build",
error: v1.metadata.error,
}
const parts = v1.parts.flatMap((part): Part[] => {
+29 -12
View File
@@ -4,16 +4,32 @@ import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
import { Global } from "../global"
import { Installation } from "../installation"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
export function init() {
Array.fromAsync(
new Bun.Glob("**/snapshot").scan({
absolute: true,
onlyFiles: false,
cwd: Global.Path.data,
}),
).then((files) => {
for (const file of files) {
fs.rmdir(file, { recursive: true })
}
})
}
export async function create() {
log.info("creating snapshot")
const app = App.info()
// not a git repo, check if too big to snapshot
if (!app.git) {
if (!app.git || !Installation.isDev()) {
return
const files = await Ripgrep.files({
cwd: app.path.cwd,
@@ -23,7 +39,7 @@ export namespace Snapshot {
if (files.length >= 1000) return
}
const git = gitdir(sessionID)
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
@@ -40,7 +56,7 @@ export namespace Snapshot {
log.info("added files")
const result =
await $`git --git-dir ${git} commit -m "snapshot" --no-gpg-sign --author="opencode <mail@opencode.ai>"`
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --no-gpg-sign --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
@@ -50,21 +66,22 @@ export namespace Snapshot {
return match![1]
}
export async function restore(sessionID: string, snapshot: string) {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const app = App.info()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${snapshot} --force`.quiet().cwd(app.path.root)
const git = gitdir()
await $`git --git-dir=${git} reset --hard ${snapshot}`.quiet().cwd(app.path.root)
}
export async function diff(sessionID: string, commit: string) {
const git = gitdir(sessionID)
export async function diff(commit: string) {
const git = gitdir()
const result = await $`git --git-dir=${git} diff -R ${commit}`.quiet().cwd(App.info().path.root)
return result.stdout.toString("utf8")
const text = result.stdout.toString("utf8")
return text
}
function gitdir(sessionID: string) {
function gitdir() {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
return path.join(app.path.data, "snapshots")
}
}
+16
View File
@@ -72,6 +72,22 @@ export namespace Storage {
} catch (e) {}
}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const content = await Bun.file(file).json()
if (content.role === "assistant" && !content.mode) {
log.info("adding mode field to message", { file })
content.mode = "build"
await Bun.write(file, JSON.stringify(content, null, 2))
}
} catch (e) {}
}
},
]
const state = App.state("storage", async () => {
+1
View File
@@ -41,6 +41,7 @@ export const TaskTool = Tool.define({
sessionID: session.id,
modelID: msg.modelID,
providerID: msg.providerID,
mode: msg.mode,
tools: {
todoread: false,
todowrite: false,
+4 -4
View File
@@ -1,4 +1,4 @@
configured_endpoints: 24
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-9574184bd9e916aa69eae8e26e0679556038d3fcfb4009a445c97c6cc3e4f3ee.yml
openapi_spec_hash: 93ba1215ab0dc853a1691b049cc47d75
config_hash: 09e4835d57ec7ed0b2d316c6815bcf0a
configured_endpoints: 26
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-1efc45c35b58e88b0550fbb0c7a204ef66522742f87c9e29c76a18b120c0d945.yml
openapi_spec_hash: 5e15d85e4704624f9b13bae1c71aa416
config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3
+2
View File
@@ -121,8 +121,10 @@ Methods:
- <code title="post /session/{id}/message">client.session.<a href="./src/resources/session.ts">chat</a>(id, { ...params }) -> AssistantMessage</code>
- <code title="post /session/{id}/init">client.session.<a href="./src/resources/session.ts">init</a>(id, { ...params }) -> SessionInitResponse</code>
- <code title="get /session/{id}/message">client.session.<a href="./src/resources/session.ts">messages</a>(id) -> SessionMessagesResponse</code>
- <code title="post /session/{id}/revert">client.session.<a href="./src/resources/session.ts">revert</a>(id, { ...params }) -> Session</code>
- <code title="post /session/{id}/share">client.session.<a href="./src/resources/session.ts">share</a>(id) -> Session</code>
- <code title="post /session/{id}/summarize">client.session.<a href="./src/resources/session.ts">summarize</a>(id, { ...params }) -> SessionSummarizeResponse</code>
- <code title="post /session/{id}/unrevert">client.session.<a href="./src/resources/session.ts">unrevert</a>(id) -> Session</code>
- <code title="delete /session/{id}/share">client.session.<a href="./src/resources/session.ts">unshare</a>(id) -> Session</code>
# Tui
+2
View File
@@ -67,6 +67,7 @@ import {
SessionListResponse,
SessionMessagesResponse,
SessionResource,
SessionRevertParams,
SessionSummarizeParams,
SessionSummarizeResponse,
SnapshotPart,
@@ -846,6 +847,7 @@ export declare namespace Opencode {
type SessionSummarizeResponse as SessionSummarizeResponse,
type SessionChatParams as SessionChatParams,
type SessionInitParams as SessionInitParams,
type SessionRevertParams as SessionRevertParams,
type SessionSummarizeParams as SessionSummarizeParams,
};
+11 -1
View File
@@ -305,10 +305,20 @@ export interface KeybindsConfig {
messages_previous: string;
/**
* Revert message
* Redo message
*/
messages_redo: string;
/**
* @deprecated use messages_undo. Revert message
*/
messages_revert: string;
/**
* Undo message
*/
messages_undo: string;
/**
* List available models
*/
+1
View File
@@ -71,6 +71,7 @@ export {
type SessionSummarizeResponse,
type SessionChatParams,
type SessionInitParams,
type SessionRevertParams,
type SessionSummarizeParams,
} from './session';
export {
+22 -1
View File
@@ -57,6 +57,13 @@ export class SessionResource extends APIResource {
return this._client.get(path`/session/${id}/message`, options);
}
/**
* Revert a message
*/
revert(id: string, body: SessionRevertParams, options?: RequestOptions): APIPromise<Session> {
return this._client.post(path`/session/${id}/revert`, { body, ...options });
}
/**
* Share a session
*/
@@ -75,6 +82,13 @@ export class SessionResource extends APIResource {
return this._client.post(path`/session/${id}/summarize`, { body, ...options });
}
/**
* Restore all reverted messages
*/
unrevert(id: string, options?: RequestOptions): APIPromise<Session> {
return this._client.post(path`/session/${id}/unrevert`, options);
}
/**
* Unshare the session
*/
@@ -231,7 +245,7 @@ export namespace Session {
export interface Revert {
messageID: string;
part: number;
partID?: string;
snapshot?: string;
}
@@ -513,6 +527,12 @@ export interface SessionInitParams {
providerID: string;
}
export interface SessionRevertParams {
messageID: string;
partID?: string;
}
export interface SessionSummarizeParams {
modelID: string;
@@ -550,6 +570,7 @@ export declare namespace SessionResource {
type SessionSummarizeResponse as SessionSummarizeResponse,
type SessionChatParams as SessionChatParams,
type SessionInitParams as SessionInitParams,
type SessionRevertParams as SessionRevertParams,
type SessionSummarizeParams as SessionSummarizeParams,
};
}
@@ -118,6 +118,23 @@ describe('resource session', () => {
expect(dataAndResponse.response).toBe(rawResponse);
});
// skipped: tests are disabled for the time being
test.skip('revert: only required params', async () => {
const responsePromise = client.session.revert('id', { messageID: 'msg' });
const rawResponse = await responsePromise.asResponse();
expect(rawResponse).toBeInstanceOf(Response);
const response = await responsePromise;
expect(response).not.toBeInstanceOf(Response);
const dataAndResponse = await responsePromise.withResponse();
expect(dataAndResponse.data).toBe(response);
expect(dataAndResponse.response).toBe(rawResponse);
});
// skipped: tests are disabled for the time being
test.skip('revert: required and optional params', async () => {
const response = await client.session.revert('id', { messageID: 'msg', partID: 'prt' });
});
// skipped: tests are disabled for the time being
test.skip('share', async () => {
const responsePromise = client.session.share('id');
@@ -147,6 +164,18 @@ describe('resource session', () => {
const response = await client.session.summarize('id', { modelID: 'modelID', providerID: 'providerID' });
});
// skipped: tests are disabled for the time being
test.skip('unrevert', async () => {
const responsePromise = client.session.unrevert('id');
const rawResponse = await responsePromise.asResponse();
expect(rawResponse).toBeInstanceOf(Response);
const response = await responsePromise;
expect(response).not.toBeInstanceOf(Response);
const dataAndResponse = await responsePromise.withResponse();
expect(dataAndResponse.data).toBe(response);
expect(dataAndResponse.response).toBe(rawResponse);
});
// skipped: tests are disabled for the time being
test.skip('unshare', async () => {
const responsePromise = client.session.unshare('id');
+25
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/signal"
@@ -51,6 +52,30 @@ func main() {
os.Exit(1)
}
stat, err := os.Stdin.Stat()
if err != nil {
slog.Error("Failed to stat stdin", "error", err)
os.Exit(1)
}
// Check if there's data piped to stdin
if (stat.Mode() & os.ModeCharDevice) == 0 {
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
slog.Error("Failed to read stdin", "error", err)
os.Exit(1)
}
stdinContent := strings.TrimSpace(string(stdin))
if stdinContent != "" {
if prompt == nil || *prompt == "" {
prompt = &stdinContent
} else {
combined := *prompt + "\n" + stdinContent
prompt = &combined
}
}
}
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
+23 -9
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"log/slog"
@@ -53,6 +52,13 @@ type SessionCreatedMsg = struct {
Session *opencode.Session
}
type SessionSelectedMsg = *opencode.Session
type MessageRevertedMsg struct {
Session opencode.Session
Message Message
}
type SessionUnrevertedMsg struct {
Session opencode.Session
}
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct {
Provider opencode.Provider
@@ -172,9 +178,24 @@ func New(
IntitialMode: initialMode,
}
if app.Version != "dev" {
delete(app.Commands, commands.MessagesUndoCommand)
delete(app.Commands, commands.MessagesRedoCommand)
}
return app, nil
}
func (a *App) Keybind(commandName commands.CommandName) string {
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return key
}
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
@@ -184,11 +205,7 @@ func (a *App) Key(commandName commands.CommandName) string {
Faint(true).
Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
key := a.Keybind(commandName)
return base(key) + muted(" "+command.Description)
}
@@ -519,9 +536,6 @@ func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
return []opencode.Session{}, nil
}
sessions := *response
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0
})
return sessions, nil
}
+68
View File
@@ -1,6 +1,7 @@
package app
import (
"errors"
"time"
"github.com/sst/opencode-sdk-go"
@@ -109,6 +110,73 @@ func (p Prompt) ToMessage(
}
}
func (m Message) ToPrompt() (*Prompt, error) {
switch m.Info.(type) {
case opencode.UserMessage:
text := ""
attachments := []*attachment.Attachment{}
for _, part := range m.Parts {
switch p := part.(type) {
case opencode.TextPart:
if p.Synthetic {
continue
}
text += p.Text + " "
case opencode.FilePart:
switch p.Source.Type {
case "file":
attachments = append(attachments, &attachment.Attachment{
ID: p.ID,
Type: "file",
Display: p.Source.Text.Value,
URL: p.URL,
Filename: p.Filename,
MediaType: p.Mime,
StartIndex: int(p.Source.Text.Start),
EndIndex: int(p.Source.Text.End),
Source: &attachment.FileSource{
Path: p.Source.Path,
Mime: p.Mime,
},
})
case "symbol":
r := p.Source.Range.(opencode.SymbolSourceRange)
attachments = append(attachments, &attachment.Attachment{
ID: p.ID,
Type: "symbol",
Display: p.Source.Text.Value,
URL: p.URL,
Filename: p.Filename,
MediaType: p.Mime,
StartIndex: int(p.Source.Text.Start),
EndIndex: int(p.Source.Text.End),
Source: &attachment.SymbolSource{
Path: p.Source.Path,
Name: p.Source.Name,
Kind: int(p.Source.Kind),
Range: attachment.SymbolRange{
Start: attachment.Position{
Line: int(r.Start.Line),
Char: int(r.Start.Character),
},
End: attachment.Position{
Line: int(r.End.Line),
Char: int(r.End.Character),
},
},
},
})
}
}
}
return &Prompt{
Text: text,
Attachments: attachments,
}, nil
}
return nil, errors.New("unknown message type")
}
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{}
for _, part := range m.Parts {
+13 -4
View File
@@ -138,7 +138,8 @@ const (
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
)
@@ -348,9 +349,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Name: MessagesUndoCommand,
Description: "undo last message",
Keybindings: parseBindings("<leader>u"),
Trigger: []string{"undo"},
},
{
Name: MessagesRedoCommand,
Description: "redo message",
Keybindings: parseBindings("<leader>r"),
Trigger: []string{"redo"},
},
{
Name: AppExitCommand,
@@ -365,7 +373,8 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
// Remove share/unshare commands if sharing is disabled
if config.Share == opencode.ConfigShareDisabled && (command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
if config.Share == opencode.ConfigShareDisabled &&
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
continue
}
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
+56 -12
View File
@@ -21,6 +21,7 @@ import (
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -57,6 +58,7 @@ type editorComponent struct {
historyIndex int // -1 means current (not in history)
currentText string // Store current text when navigating history
pasteCounter int
reverted bool
}
func (m *editorComponent) Init() tea.Cmd {
@@ -122,12 +124,52 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.reverted = false
m.textarea, cmd = m.textarea.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID {
switch msg.Message.Info.(type) {
case opencode.UserMessage:
prompt, err := msg.Message.ToPrompt()
if err != nil {
return m, toast.NewErrorToast("Failed to revert message")
}
m.RestoreFromPrompt(*prompt)
m.textarea.MoveToEnd()
m.reverted = true
return m, nil
}
}
case app.SessionUnrevertedMsg:
if msg.Session.ID == m.app.Session.ID {
if m.reverted {
updated, cmd := m.Clear()
m = updated.(*editorComponent)
return m, cmd
}
return m, nil
}
case tea.PasteMsg:
text := string(msg)
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
statPath := filePath
if !filepath.IsAbs(filePath) {
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
if _, err := os.Stat(statPath); err == nil {
attachment := m.createAttachmentFromPath(filePath)
if attachment != nil {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
}
}
text = strings.ReplaceAll(text, "\\", "")
text, err := strconv.Unquote(`"` + text + `"`)
if err != nil {
@@ -176,7 +218,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeSelectedMsg:
m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner()
return m, m.textarea.Focus()
return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
case dialog.CompletionSelectedMsg:
switch msg.Item.ProviderID {
case "commands":
@@ -630,21 +672,14 @@ func NewEditorComponent(app *app.App) EditorComponent {
return m
}
// RestoreFromHistory restores a message from history at the given index
func (m *editorComponent) RestoreFromHistory(index int) {
if index < 0 || index >= len(m.app.State.MessageHistory) {
return
}
entry := m.app.State.MessageHistory[index]
func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
m.textarea.Reset()
m.textarea.SetValue(entry.Text)
m.textarea.SetValue(prompt.Text)
// Sort attachments by start index in reverse order (process from end to beginning)
// This prevents index shifting issues
attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
copy(attachmentsCopy, entry.Attachments)
attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
copy(attachmentsCopy, prompt.Attachments)
for i := 0; i < len(attachmentsCopy)-1; i++ {
for j := i + 1; j < len(attachmentsCopy); j++ {
@@ -661,6 +696,15 @@ func (m *editorComponent) RestoreFromHistory(index int) {
}
}
// RestoreFromHistory restores a message from history at the given index
func (m *editorComponent) RestoreFromHistory(index int) {
if index < 0 || index >= len(m.app.State.MessageHistory) {
return
}
entry := m.app.State.MessageHistory[index]
m.RestoreFromPrompt(entry)
}
func getMediaTypeFromExtension(ext string) string {
switch strings.ToLower(ext) {
case ".jpg":
@@ -1,6 +1,7 @@
package chat
import (
"context"
"fmt"
"log/slog"
"slices"
@@ -11,6 +12,7 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
@@ -31,6 +33,8 @@ type MessagesComponent interface {
GotoTop() (tea.Model, tea.Cmd)
GotoBottom() (tea.Model, tea.Cmd)
CopyLastMessage() (tea.Model, tea.Cmd)
UndoLastMessage() (tea.Model, tea.Cmd)
RedoLastMessage() (tea.Model, tea.Cmd)
}
type messagesComponent struct {
@@ -161,10 +165,22 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = true
m.loading = true
return m, m.renderView()
case app.SessionUnrevertedMsg:
if msg.Session.ID == m.app.Session.ID {
m.cache.Clear()
m.tail = true
return m, m.renderView()
}
case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID {
m.cache.Clear()
m.tail = true
return m, m.renderView()
}
case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == m.app.Session.ID {
m.header = m.renderHeader()
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.SessionID == m.app.Session.ID {
@@ -205,7 +221,6 @@ type renderCompleteMsg struct {
}
func (m *messagesComponent) renderView() tea.Cmd {
if m.rendering {
slog.Debug("pending render, skipping")
m.dirty = true
@@ -233,6 +248,9 @@ func (m *messagesComponent) renderView() tea.Cmd {
width := m.width // always use full width
reverted := false
revertedMessageCount := 0
revertedToolCount := 0
lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
for _, msg := range slices.Backward(m.app.Messages) {
if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
@@ -246,6 +264,17 @@ func (m *messagesComponent) renderView() tea.Cmd {
switch casted := message.Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
reverted = true
revertedMessageCount = 1
revertedToolCount = 0
continue
}
if reverted {
revertedMessageCount++
continue
}
for partIndex, part := range message.Parts {
switch part := part.(type) {
case opencode.TextPart:
@@ -324,10 +353,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
reverted = true
revertedMessageCount = 1
revertedToolCount = 0
}
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
case opencode.TextPart:
if reverted {
continue
}
hasTextPart = true
finished := part.Time.End > 0
remainingParts := message.Parts[partIndex+1:]
@@ -406,6 +443,10 @@ func (m *messagesComponent) renderView() tea.Cmd {
blocks = append(blocks, content)
}
case opencode.ToolPart:
if reverted {
revertedToolCount++
continue
}
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
@@ -472,7 +513,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
if error != "" {
if error != "" && !reverted {
error = styles.NewStyle().Width(width - 6).Render(error)
error = renderContentBlock(
m.app,
@@ -491,6 +532,44 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
if revertedMessageCount > 0 || revertedToolCount > 0 {
messagePlural := ""
toolPlural := ""
if revertedMessageCount != 1 {
messagePlural = "s"
}
if revertedToolCount != 1 {
toolPlural = "s"
}
revertedStyle := styles.NewStyle().
Background(t.BackgroundPanel()).
Foreground(t.TextMuted())
content := revertedStyle.Render(fmt.Sprintf(
"%d message%s reverted, %d tool call%s reverted",
revertedMessageCount,
messagePlural,
revertedToolCount,
toolPlural,
))
hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
hint += revertedStyle.Render(" (or /redo) to restore")
content += "\n" + hint
content = styles.NewStyle().
Background(t.BackgroundPanel()).
Width(width - 6).
Render(content)
content = renderContentBlock(
m.app,
content,
width,
WithBorderColor(t.BackgroundPanel()),
)
blocks = append(blocks, content)
}
final := []string{}
clipboard := []string{}
var selection *selection
@@ -522,7 +601,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
clipboard = append(clipboard, middle)
line = prefix + styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Render(middle) + suffix
line = prefix + styles.NewStyle().
Background(t.Accent()).
Foreground(t.BackgroundPanel()).
Render(ansi.Strip(middle)) +
suffix
}
final = append(final, line)
}
@@ -595,7 +678,7 @@ func (m *messagesComponent) renderHeader() string {
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
headerText := util.ToMarkdown(
"# "+m.app.Session.Title,
headerWidth-len(sessionInfo),
headerWidth,
t.Background(),
)
@@ -773,6 +856,162 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
after := float64(0)
var revertedMessage app.Message
reversedMessages := []app.Message{}
for i := len(m.app.Messages) - 1; i >= 0; i-- {
reversedMessages = append(reversedMessages, m.app.Messages[i])
switch casted := m.app.Messages[i].Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
after = casted.Time.Created
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
after = casted.Time.Created
}
}
if m.app.Session.Revert.PartID != "" {
for _, part := range m.app.Messages[i].Parts {
switch casted := part.(type) {
case opencode.TextPart:
if casted.ID == m.app.Session.Revert.PartID {
after = casted.Time.Start
}
case opencode.ToolPart:
// TODO: handle tool parts
}
}
}
}
messageID := ""
for _, msg := range reversedMessages {
switch casted := msg.Info.(type) {
case opencode.UserMessage:
if after > 0 && casted.Time.Created >= after {
continue
}
messageID = casted.ID
revertedMessage = msg
}
if messageID != "" {
break
}
}
if messageID == "" {
return m, nil
}
return m, func() tea.Msg {
response, err := m.app.Client.Session.Revert(
context.Background(),
m.app.Session.ID,
opencode.SessionRevertParams{
MessageID: opencode.F(messageID),
},
)
if err != nil {
slog.Error("Failed to undo message", "error", err)
return toast.NewErrorToast("Failed to undo message")
}
if response == nil {
return toast.NewErrorToast("Failed to undo message")
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}
}
func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
// Check if there's a revert state to redo from
if m.app.Session.Revert.MessageID == "" {
return m, func() tea.Msg {
return toast.NewErrorToast("Nothing to redo")
}
}
before := float64(0)
var revertedMessage app.Message
for _, message := range m.app.Messages {
switch casted := message.Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
before = casted.Time.Created
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
before = casted.Time.Created
}
}
if m.app.Session.Revert.PartID != "" {
for _, part := range message.Parts {
switch casted := part.(type) {
case opencode.TextPart:
if casted.ID == m.app.Session.Revert.PartID {
before = casted.Time.Start
}
case opencode.ToolPart:
// TODO: handle tool parts
}
}
}
}
messageID := ""
for _, msg := range m.app.Messages {
switch casted := msg.Info.(type) {
case opencode.UserMessage:
if casted.Time.Created <= before {
continue
}
messageID = casted.ID
revertedMessage = msg
}
if messageID != "" {
break
}
}
if messageID == "" {
return m, func() tea.Msg {
// unrevert back to original state
response, err := m.app.Client.Session.Unrevert(
context.Background(),
m.app.Session.ID,
)
if err != nil {
slog.Error("Failed to unrevert session", "error", err)
return toast.NewErrorToast("Failed to redo message")
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
}
return app.SessionUnrevertedMsg{Session: *response}
}
}
return m, func() tea.Msg {
// calling revert on a "later" message is like a redo
response, err := m.app.Client.Session.Revert(
context.Background(),
m.app.Session.ID,
opencode.SessionRevertParams{
MessageID: opencode.F(messageID),
},
)
if err != nil {
slog.Error("Failed to redo message", "error", err)
return toast.NewErrorToast("Failed to redo message")
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}
}
func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New()
vp.KeyMap = viewport.KeyMap{}
@@ -34,7 +34,7 @@ func createTestList() *listComponent[testItem] {
}
list := NewListComponent(
WithItems(items),
WithMaxVisibleItems[testItem](5),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](false),
WithRenderFunc(
@@ -45,9 +45,6 @@ func createTestList() *listComponent[testItem] {
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
return list.(*listComponent[testItem])
@@ -84,7 +81,7 @@ func TestJKKeyNavigation(t *testing.T) {
// Create list with alpha keys enabled
list := NewListComponent(
WithItems(items),
WithMaxVisibleItems[testItem](5),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](true),
WithRenderFunc(
@@ -95,9 +92,6 @@ func TestJKKeyNavigation(t *testing.T) {
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
// Test j key (down)
@@ -176,7 +170,7 @@ func TestNavigationBoundaries(t *testing.T) {
func TestEmptyList(t *testing.T) {
emptyList := NewListComponent(
WithItems([]testItem{}),
WithMaxVisibleItems[testItem](5),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](false),
WithRenderFunc(
@@ -187,9 +181,6 @@ func TestEmptyList(t *testing.T) {
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
// Test navigation on empty list (should not crash)
@@ -0,0 +1,69 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg": "#0f0f0f",
"darkBgPanel": "#15141b",
"darkBorder": "#2d2d2d",
"darkFgMuted": "#6d6d6d",
"darkFg": "#edecee",
"purple": "#a277ff",
"pink": "#f694ff",
"blue": "#82e2ff",
"red": "#ff6767",
"orange": "#ffca85",
"cyan": "#61ffca",
"green": "#9dff65"
},
"theme": {
"primary": "purple",
"secondary": "pink",
"accent": "purple",
"error": "red",
"warning": "orange",
"success": "cyan",
"info": "purple",
"text": "darkFg",
"textMuted": "darkFgMuted",
"background": "darkBg",
"backgroundPanel": "darkBgPanel",
"backgroundElement": "darkBgPanel",
"border": "darkBorder",
"borderActive": "darkFgMuted",
"borderSubtle": "darkBorder",
"diffAdded": "cyan",
"diffRemoved": "red",
"diffContext": "darkFgMuted",
"diffHunkHeader": "darkFgMuted",
"diffHighlightAdded": "cyan",
"diffHighlightRemoved": "red",
"diffAddedBg": "#354933",
"diffRemovedBg": "#3f191a",
"diffContextBg": "darkBgPanel",
"diffLineNumber": "darkBorder",
"diffAddedLineNumberBg": "#162620",
"diffRemovedLineNumberBg": "#26161a",
"markdownText": "darkFg",
"markdownHeading": "purple",
"markdownLink": "pink",
"markdownLinkText": "purple",
"markdownCode": "cyan",
"markdownBlockQuote": "darkFgMuted",
"markdownEmph": "orange",
"markdownStrong": "purple",
"markdownHorizontalRule": "darkFgMuted",
"markdownListItem": "purple",
"markdownListEnumeration": "purple",
"markdownImage": "pink",
"markdownImageText": "purple",
"markdownCodeBlock": "darkFg",
"syntaxComment": "darkFgMuted",
"syntaxKeyword": "pink",
"syntaxFunction": "purple",
"syntaxVariable": "purple",
"syntaxString": "cyan",
"syntaxNumber": "green",
"syntaxType": "purple",
"syntaxOperator": "pink",
"syntaxPunctuation": "darkFg"
}
}
+12 -1
View File
@@ -470,6 +470,10 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.SessionCreatedMsg:
a.app.Session = msg.Session
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.MessageRevertedMsg:
if msg.Session.ID == a.app.Session.ID {
a.app.Session = &msg.Session
}
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
@@ -1045,7 +1049,14 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
updated, cmd := a.messages.CopyLastMessage()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesRevertCommand:
case commands.MessagesUndoCommand:
updated, cmd := a.messages.UndoLastMessage()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesRedoCommand:
updated, cmd := a.messages.RedoLastMessage()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.AppExitCommand:
return a, tea.Quit
}
+12 -2
View File
@@ -66,12 +66,22 @@ func (h *APILogHandler) Handle(ctx context.Context, r slog.Record) error {
h.mu.Lock()
for _, attr := range h.attrs {
extra[attr.Key] = attr.Value.Any()
val := attr.Value.Any()
if err, ok := val.(error); ok {
extra[attr.Key] = err.Error()
} else {
extra[attr.Key] = val
}
}
h.mu.Unlock()
r.Attrs(func(attr slog.Attr) bool {
extra[attr.Key] = attr.Value.Any()
val := attr.Value.Any()
if err, ok := val.(error); ok {
extra[attr.Key] = err.Error()
} else {
extra[attr.Key] = val
}
return true
})
+4 -4
View File
@@ -1,4 +1,4 @@
configured_endpoints: 24
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-9574184bd9e916aa69eae8e26e0679556038d3fcfb4009a445c97c6cc3e4f3ee.yml
openapi_spec_hash: 93ba1215ab0dc853a1691b049cc47d75
config_hash: 09e4835d57ec7ed0b2d316c6815bcf0a
configured_endpoints: 26
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-1efc45c35b58e88b0550fbb0c7a204ef66522742f87c9e29c76a18b120c0d945.yml
openapi_spec_hash: 5e15d85e4704624f9b13bae1c71aa416
config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3
+2
View File
@@ -114,8 +114,10 @@ Methods:
- <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#SessionChatParams">SessionChatParams</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#AssistantMessage">AssistantMessage</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#SessionInitParams">SessionInitParams</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 /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#SessionMessagesResponse">SessionMessagesResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/revert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Revert">Revert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#SessionRevertParams">SessionRevertParams</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#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/summarize">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Summarize">Summarize</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#SessionSummarizeParams">SessionSummarizeParams</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 /session/{id}/unrevert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unrevert">Unrevert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</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#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Tui
+7 -1
View File
@@ -510,8 +510,12 @@ type KeybindsConfig struct {
MessagesPageUp string `json:"messages_page_up,required"`
// Navigate to previous message
MessagesPrevious string `json:"messages_previous,required"`
// Revert message
// Redo message
MessagesRedo string `json:"messages_redo,required"`
// @deprecated use messages_undo. Revert message
MessagesRevert string `json:"messages_revert,required"`
// Undo message
MessagesUndo string `json:"messages_undo,required"`
// List available models
ModelList string `json:"model_list,required"`
// Create/update AGENTS.md
@@ -565,7 +569,9 @@ type keybindsConfigJSON struct {
MessagesPageDown apijson.Field
MessagesPageUp apijson.Field
MessagesPrevious apijson.Field
MessagesRedo apijson.Field
MessagesRevert apijson.Field
MessagesUndo apijson.Field
ModelList apijson.Field
ProjectInit apijson.Field
SessionCompact apijson.Field
+35 -2
View File
@@ -112,6 +112,18 @@ func (r *SessionService) Messages(ctx context.Context, id string, opts ...option
return
}
// Revert a message
func (r *SessionService) Revert(ctx context.Context, id string, body SessionRevertParams, opts ...option.RequestOption) (res *Session, err error) {
opts = append(r.Options[:], opts...)
if id == "" {
err = errors.New("missing required id parameter")
return
}
path := fmt.Sprintf("session/%s/revert", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
return
}
// Share a session
func (r *SessionService) Share(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
opts = append(r.Options[:], opts...)
@@ -136,6 +148,18 @@ func (r *SessionService) Summarize(ctx context.Context, id string, body SessionS
return
}
// Restore all reverted messages
func (r *SessionService) Unrevert(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
opts = append(r.Options[:], opts...)
if id == "" {
err = errors.New("missing required id parameter")
return
}
path := fmt.Sprintf("session/%s/unrevert", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...)
return
}
// Unshare the session
func (r *SessionService) Unshare(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
opts = append(r.Options[:], opts...)
@@ -988,7 +1012,7 @@ func (r sessionTimeJSON) RawJSON() string {
type SessionRevert struct {
MessageID string `json:"messageID,required"`
Part float64 `json:"part,required"`
PartID string `json:"partID"`
Snapshot string `json:"snapshot"`
JSON sessionRevertJSON `json:"-"`
}
@@ -996,7 +1020,7 @@ type SessionRevert struct {
// sessionRevertJSON contains the JSON metadata for the struct [SessionRevert]
type sessionRevertJSON struct {
MessageID apijson.Field
Part apijson.Field
PartID apijson.Field
Snapshot apijson.Field
raw string
ExtraFields map[string]apijson.Field
@@ -2010,6 +2034,15 @@ func (r SessionInitParams) MarshalJSON() (data []byte, err error) {
return apijson.MarshalRoot(r)
}
type SessionRevertParams struct {
MessageID param.Field[string] `json:"messageID,required"`
PartID param.Field[string] `json:"partID"`
}
func (r SessionRevertParams) MarshalJSON() (data []byte, err error) {
return apijson.MarshalRoot(r)
}
type SessionSummarizeParams struct {
ModelID param.Field[string] `json:"modelID,required"`
ProviderID param.Field[string] `json:"providerID,required"`
+51
View File
@@ -197,6 +197,35 @@ func TestSessionMessages(t *testing.T) {
}
}
func TestSessionRevertWithOptionalParams(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.Session.Revert(
context.TODO(),
"id",
opencode.SessionRevertParams{
MessageID: opencode.F("msg"),
PartID: opencode.F("prt"),
},
)
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())
}
}
func TestSessionShare(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
@@ -248,6 +277,28 @@ func TestSessionSummarize(t *testing.T) {
}
}
func TestSessionUnrevert(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.Session.Unrevert(context.TODO(), "id")
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())
}
}
func TestSessionUnshare(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
+1 -1
View File
@@ -63,6 +63,7 @@ export default defineConfig({
sidebar: [
"docs",
"docs/cli",
"docs/ide",
"docs/share",
"docs/modes",
"docs/rules",
@@ -72,7 +73,6 @@ export default defineConfig({
"docs/keybinds",
"docs/enterprise",
"docs/mcp-servers",
"docs/using-in-ide",
"docs/troubleshooting",
],
components: {
+1 -1
View File
@@ -60,7 +60,7 @@ opencode auth [command]
#### login
Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`.
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
```bash
opencode auth login
@@ -99,26 +99,6 @@ Logs are written to:
- **macOS/Linux**: `~/.local/share/opencode/log/`
- **Windows**: `%APPDATA%\opencode\log\`
You can configure the minimum log level through the `log_level` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"log_level": "INFO"
}
```
With the following options:
| Level | Description |
| ------- | ---------------------------------------- |
| `DEBUG` | All messages including debug information |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
The **default** log level is `INFO`. If you are running opencode locally in development mode it's set to `DEBUG`.
---
### Sharing
@@ -1,23 +1,23 @@
---
title: Using in IDE
description: Using opencode in your favorite IDE
title: IDE
description: Using opencode with VS Code, Cursor, and other IDEs
---
opencode works seamlessly with VS Code, Cursor, or any IDE that supports a terminal. Just run `opencode` in the terminal to get started.
opencode integrates with VS Code, Cursor, or any IDE that supports a terminal. Just run `opencode` in the terminal to get started.
---
## Features
## Usage
- **Quick Launch**: Open opencode with `Cmd+Esc` (Mac) or `Ctrl+Esc` (Windows/Linux), or click the opencode button in the UI.
- **Context Awareness**: Automatically shares your current selection or tab with opencode.
- **File reference shortcuts**: Use `Cmd+Option+K` (Mac) or `Alt+Ctrl+K` (Linux/Windows) to insert file references (ie., @File#L37-42)
- **Context Awareness**: Automatically share your current selection or tab with opencode.
- **File Reference Shortcuts**: Use `Cmd+Option+K` (Mac) or `Alt+Ctrl+K` (Linux/Windows) to insert file references. For example, `@File#L37-42`.
---
## Installation
To install opencode on VS Code and popular forks (Cursor, Windsurf, VSCodium):
To install opencode on VS Code and popular forks like Cursor, Windsurf, VSCodium:
1. Open VS Code
2. Open the integrated terminal
@@ -27,13 +27,13 @@ To install opencode on VS Code and popular forks (Cursor, Windsurf, VSCodium):
### Manual Install
Search for `opencode` in the Extension Marketplace and click **Install**.
Search for **opencode** in the Extension Marketplace and click **Install**.
---
### Troubleshooting
If the extension doesn't install:
If the extension fails to install automatically:
- Ensure youre running `opencode` in the integrated terminal.
- Confirm the CLI for your IDE is installed:
+204 -48
View File
@@ -5,73 +5,74 @@ description: Get started with opencode.
import { Tabs, TabItem } from "@astrojs/starlight/components"
[**opencode**](/) is an AI coding agent built for the terminal. It features:
- A responsive, native, themeable terminal UI.
- Automatically loads the right LSPs, so the LLMs make fewer mistakes.
- Have multiple agents working in parallel on the same project.
- Create shareable links to any session for reference or to debug.
- Log in with Anthropic to use your Claude Pro or Claude Max account.
- Supports 75+ LLM providers through [Models.dev](https://models.dev), including local models.
[**opencode**](/) is an AI coding agent built for the terminal.
![opencode TUI with the opencode theme](../../../assets/lander/screenshot.png)
Let's start by installing opencode.
---
## Install
<Tabs>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
</Tabs>
You can also install the opencode binary through the following.
##### Using the install script
The easiest way to install opencode is through the install script.
```bash
curl -fsSL https://opencode.ai/install | bash
```
##### Using Homebrew on macOS
You can also install it with the following:
```bash
brew install sst/tap/opencode
```
- **Using Node.js**
##### Using Paru on Arch Linux
<Tabs>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
</Tabs>
```bash
paru -S opencode-bin
```
- **Using Homebrew on macOS**
##### Windows
```bash
brew install sst/tap/opencode
```
- **Using Paru on Arch Linux**
```bash
paru -S opencode-bin
```
#### Windows
Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
---
## Providers
## Configure
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
With opencode you can use any LLM provider by configuring their API keys.
We recommend signing up for [Claude Pro](https://www.anthropic.com/news/claude-pro) or [Max](https://www.anthropic.com/max), it's the most cost-effective way to use opencode.
Once you've singed up, run `opencode auth login` and select Anthropic.
```bash
$ opencode auth login
@@ -90,8 +91,163 @@ $ opencode auth login
```
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
Alternatively, you can select one of the other providers and adding their API keys.
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
---
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). You can also [add them to your config](/docs/config) for yourself.
## Initialize
Now that you've configured a provider, you can navigate to a project that
you want to work on.
```bash
cd /path/to/project
```
And run opencode.
```bash
opencode
```
Next, initialize opencode for the project by running the following command.
```bash frame="none"
/init
```
This will get opencode to analyze your project and create an `AGENTS.md` file in
the project root.
:::tip
You should commit your project's `AGENTS.md` file to Git.
:::
This helps opencode understand the project structure and the coding patterns
used.
---
## Usage
You are now ready to use opencode to work on your project. Feel free to ask it
anything!
If you are new to using an AI coding agent, here are some examples that might
help.
---
### Ask questions
You can ask opencode to explain the codebase to you.
```txt frame="none"
How is authentication handled in @packages/functions/src/api/index.ts
```
This is helpful if there's a part of the codebase that you didn't work on.
:::tip
Use the `@` key to fuzzy search for files in the project.
:::
---
### Add features
You can ask opencode to add new features to your project. Though we first recommend asking it to create a plan.
1. **Create a plan**
opencode has a _Plan mode_ that disables its ability to make changes and
instead suggest _how_ it'll implement the feature.
Switch to it using the **Tab** key. You'll see an indicator for this in the lower right corner.
```bash frame="none" title="Switch to Plan mode"
<TAB>
```
Now let's describe what we want it to do.
```txt frame="none"
When a user deletes a note, we'd like to flag it as deleted in the database.
Then create a screen that shows all the recently deleted notes.
From this screen, the user can undelete a note or permanently delete it.
```
You want to give opencode enough details to understand what you want. It helps
to talk to it like you are talking to a junior developer on your team.
:::tip
Give opencode plenty of context and examples to help it understand what you
want.
:::
2. **Iterate on the plan**
Once it gives you a plan, you can give it feedback or add more details.
```txt frame="none"
We'd like to design this new screen using the design we are already using.
There's an example of a list of notes in @packages/web/src/components/NoteList.tsx
```
3. **Build the feature**
Once you feel comfortable with the plan, switch back to _Build mode_ by
hitting the **Tab** key again.
```bash frame="none"
<TAB>
```
And asking it to make the changes.
```bash frame="none"
Sounds good! Go ahead and make the changes.
```
---
### Make changes
For more straightforward changes, you can ask opencode to directly build it
without having to review the plan first.
```txt frame="none"
We need to add authentication to the /settings route. Take a look at how this is
handled in the /notes route in @packages/functions/src/notes.ts and implement
the same logic in @packages/functions/src/settings.ts
```
You want to make sure you provide a good amount of detail so opencode makes the right
changes.
---
## Share
The conversations that you have with opencode can be [shared with your
team](/docs/share).
```bash frame="none"
/share
```
This'll create a link to the current conversation and copy it to your clipboard.
:::note
Conversations are not shared by default.
:::
Here's an [example conversation](https://opencode.ai/s/4XP1fce5) with opencode.
---
## Customize
And that's it! You are now a pro at using opencode.
To make it your own, we recommend [picking a theme](/docs/themes), [customizing the keybinds](/docs/keybinds), or playing around with the [opencode config](/docs/config).
+2 -2
View File
@@ -43,8 +43,8 @@ let state:
async function run() {
try {
const match = body.match(/^hey\s*opencode,?\s*(.*)$/)
if (!match?.[1]) throw new Error("Command must start with `hey opencode`")
const match = body.match(/^hey\s*opencode,/)
if (!match?.[1]) throw new Error("Command must start with `hey opencode,`")
const userPrompt = match[1]
const oidcToken = await generateGitHubToken()
+2
View File
@@ -120,6 +120,8 @@ resources:
summarize: post /session/{id}/summarize
messages: get /session/{id}/message
chat: post /session/{id}/message
revert: post /session/{id}/revert
unrevert: post /session/{id}/unrevert
tui:
methods: