summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2025-07-23 20:30:46 -0400
committerGitHub <[email protected]>2025-07-23 20:30:46 -0400
commit96866e52ce4b28d8f380d84722f411e786781f51 (patch)
treefbe8df4e706510b3158d1659d8aa54b0c533c849
parent507c975e929d1d8bd58ae26e7bbcf9537daaf5b6 (diff)
downloadopencode-96866e52ce4b28d8f380d84722f411e786781f51.tar.gz
opencode-96866e52ce4b28d8f380d84722f411e786781f51.zip
basic undo feature (#1268)
Co-authored-by: adamdotdevin <[email protected]> Co-authored-by: Jay V <[email protected]> Co-authored-by: Aiden Cline <[email protected]> Co-authored-by: Andrew Joslin <[email protected]> Co-authored-by: GitHub Action <[email protected]> Co-authored-by: Tobias Walle <[email protected]>
-rw-r--r--packages/opencode/src/cli/bootstrap.ts2
-rw-r--r--packages/opencode/src/cli/cmd/debug/snapshot.ts36
-rw-r--r--packages/opencode/src/config/config.ts9
-rw-r--r--packages/opencode/src/server/server.ts74
-rw-r--r--packages/opencode/src/session/index.ts129
-rw-r--r--packages/opencode/src/session/message-v2.ts7
-rw-r--r--packages/opencode/src/snapshot/index.ts38
-rw-r--r--packages/sdk/.stats.yml8
-rw-r--r--packages/sdk/api.md2
-rw-r--r--packages/sdk/src/client.ts2
-rw-r--r--packages/sdk/src/resources/config.ts12
-rw-r--r--packages/sdk/src/resources/index.ts1
-rw-r--r--packages/sdk/src/resources/session.ts23
-rw-r--r--packages/sdk/tests/api-resources/session.test.ts29
-rw-r--r--packages/tui/internal/app/app.go23
-rw-r--r--packages/tui/internal/app/prompt.go68
-rw-r--r--packages/tui/internal/commands/command.go17
-rw-r--r--packages/tui/internal/components/chat/editor.go50
-rw-r--r--packages/tui/internal/components/chat/messages.go240
-rw-r--r--packages/tui/internal/tui/tui.go13
-rw-r--r--packages/tui/sdk/.stats.yml8
-rw-r--r--packages/tui/sdk/api.md2
-rw-r--r--packages/tui/sdk/config.go8
-rw-r--r--packages/tui/sdk/session.go37
-rw-r--r--packages/tui/sdk/session_test.go51
-rw-r--r--stainless.yml2
26 files changed, 766 insertions, 125 deletions
diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts
index 4419773b4..3af9809bc 100644
--- a/packages/opencode/src/cli/bootstrap.ts
+++ b/packages/opencode/src/cli/bootstrap.ts
@@ -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)
})
diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts
index 48d7f91e6..36f89f337 100644
--- a/packages/opencode/src/cli/cmd/debug/snapshot.ts
+++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts
@@ -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)
+ })
+ },
+})
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index da65986c9..5020194c8 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -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()
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index b096f20f5..1b123f532 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -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",
@@ -461,6 +466,61 @@ 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
+ 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({
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index e4252b10e..e39f7fa4c 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -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)
@@ -531,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,
+ 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,
})
- 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)
}
- 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
@@ -831,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"),
@@ -895,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"),
@@ -924,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"),
@@ -1043,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({
@@ -1067,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
- }
- 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,
+ 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(true))
+ 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)
}
- })
- */
+ }
}
- 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 }) {
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index ebc7483dc..6efa243d8 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -272,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) {
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index f98909e94..e147ba129 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -4,11 +4,26 @@ import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
+import { Global } from "../global"
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(force?: boolean) {
log.info("creating snapshot")
const app = App.info()
@@ -23,7 +38,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 +55,7 @@ export namespace Snapshot {
log.info("added files")
const result =
- await $`git --git-dir ${git} commit -m "snapshot" --no-gpg-sign --author="opencode <[email protected]>"`
+ await $`git --git-dir ${git} commit ${force ? "--allow-empty" : ""} -m "snapshot" --no-gpg-sign --author="opencode <[email protected]>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
@@ -50,21 +65,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")
}
}
diff --git a/packages/sdk/.stats.yml b/packages/sdk/.stats.yml
index f9f86831f..da0b08b5a 100644
--- a/packages/sdk/.stats.yml
+++ b/packages/sdk/.stats.yml
@@ -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
diff --git a/packages/sdk/api.md b/packages/sdk/api.md
index 7cbaaf6c5..d1f4a482f 100644
--- a/packages/sdk/api.md
+++ b/packages/sdk/api.md
@@ -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
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index c514ca877..7045719f6 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -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,
};
diff --git a/packages/sdk/src/resources/config.ts b/packages/sdk/src/resources/config.ts
index e1fc09de5..638af98f8 100644
--- a/packages/sdk/src/resources/config.ts
+++ b/packages/sdk/src/resources/config.ts
@@ -305,11 +305,21 @@ 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
*/
model_list: string;
diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts
index 50df8ff46..db8bde9b4 100644
--- a/packages/sdk/src/resources/index.ts
+++ b/packages/sdk/src/resources/index.ts
@@ -71,6 +71,7 @@ export {
type SessionSummarizeResponse,
type SessionChatParams,
type SessionInitParams,
+ type SessionRevertParams,
type SessionSummarizeParams,
} from './session';
export {
diff --git a/packages/sdk/src/resources/session.ts b/packages/sdk/src/resources/session.ts
index 6e160321d..bb761033a 100644
--- a/packages/sdk/src/resources/session.ts
+++ b/packages/sdk/src/resources/session.ts
@@ -58,6 +58,13 @@ export class SessionResource extends APIResource {
}
/**
+ * 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
*/
share(id: string, options?: RequestOptions): APIPromise<Session> {
@@ -76,6 +83,13 @@ export class SessionResource extends APIResource {
}
/**
+ * Restore all reverted messages
+ */
+ unrevert(id: string, options?: RequestOptions): APIPromise<Session> {
+ return this._client.post(path`/session/${id}/unrevert`, options);
+ }
+
+ /**
* Unshare the session
*/
unshare(id: string, options?: RequestOptions): APIPromise<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,
};
}
diff --git a/packages/sdk/tests/api-resources/session.test.ts b/packages/sdk/tests/api-resources/session.test.ts
index efa1d9db4..1c4984a62 100644
--- a/packages/sdk/tests/api-resources/session.test.ts
+++ b/packages/sdk/tests/api-resources/session.test.ts
@@ -119,6 +119,23 @@ describe('resource session', () => {
});
// 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');
const rawResponse = await responsePromise.asResponse();
@@ -148,6 +165,18 @@ describe('resource session', () => {
});
// 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');
const rawResponse = await responsePromise.asResponse();
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 0556750a0..df4e209cf 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -52,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
@@ -174,6 +181,16 @@ func New(
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
@@ -183,11 +200,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)
}
diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go
index 9176b7df5..282ced704 100644
--- a/packages/tui/internal/app/prompt.go
+++ b/packages/tui/internal/app/prompt.go
@@ -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 {
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index 6015ab85b..8d1f3a6c4 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -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 != "" {
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 4a5212786..4311a4ca7 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -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,10 +124,34 @@ 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)
@@ -646,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++ {
@@ -677,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":
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index cbea349ca..e498de9f0 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -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)
}
@@ -773,6 +856,155 @@ 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) {
+ 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{}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 752e805ea..ee0b22b48 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -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
}
diff --git a/packages/tui/sdk/.stats.yml b/packages/tui/sdk/.stats.yml
index f9f86831f..da0b08b5a 100644
--- a/packages/tui/sdk/.stats.yml
+++ b/packages/tui/sdk/.stats.yml
@@ -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
diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md
index 1f5191cad..0bb72433c 100644
--- a/packages/tui/sdk/api.md
+++ b/packages/tui/sdk/api.md
@@ -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
diff --git a/packages/tui/sdk/config.go b/packages/tui/sdk/config.go
index 0461cba87..96759fc47 100644
--- a/packages/tui/sdk/config.go
+++ b/packages/tui/sdk/config.go
@@ -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
diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go
index 8426b64eb..86d46000d 100644
--- a/packages/tui/sdk/session.go
+++ b/packages/tui/sdk/session.go
@@ -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"`
diff --git a/packages/tui/sdk/session_test.go b/packages/tui/sdk/session_test.go
index 5d7c55cad..807f19563 100644
--- a/packages/tui/sdk/session_test.go
+++ b/packages/tui/sdk/session_test.go
@@ -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"
diff --git a/stainless.yml b/stainless.yml
index 317238438..3bc7ab0e0 100644
--- a/stainless.yml
+++ b/stainless.yml
@@ -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: