mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a11999137f | |||
| a16554d445 | |||
| 2553137395 | |||
| 6b6b81556f | |||
| ff23f67ad5 | |||
| 8f0644e35b | |||
| 3fdd23df16 | |||
| 2c82ee592c | |||
| 1ad529db59 | |||
| 96866e52ce | |||
| 507c975e92 | |||
| 3e69d5276b | |||
| 289a4d9b18 | |||
| 12bf5f641d | |||
| 2051e85e96 | |||
| 12b86829d9 | |||
| 6c9ec54129 | |||
| b7b0cdbd7c | |||
| fd98c3189a | |||
| 1278353616 | |||
| 638ec7bc50 | |||
| 38ae7d60aa | |||
| 2d1f9fc321 | |||
| ee0c8132db | |||
| c2208fa1f9 | |||
| bf42d8b011 |
@@ -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) |
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 }),
|
||||
])
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -71,6 +71,7 @@ export {
|
||||
type SessionSummarizeResponse,
|
||||
type SessionChatParams,
|
||||
type SessionInitParams,
|
||||
type SessionRevertParams,
|
||||
type SessionSummarizeParams,
|
||||
} from './session';
|
||||
export {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-9
@@ -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 you’re running `opencode` in the integrated terminal.
|
||||
- Confirm the CLI for your IDE is installed:
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user