Просмотр исходного кода

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]>
Dax 8 месяцев назад
Родитель
Сommit
96866e52ce

+ 2 - 0
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)
   })

+ 32 - 4
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)
+    })
+  },
+})

+ 7 - 2
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()

+ 67 - 7
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({

+ 66 - 63
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 }) {

+ 7 - 0
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) {

+ 27 - 11
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")
   }
 }

+ 4 - 4
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

+ 2 - 0
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

+ 2 - 0
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,
   };
 

+ 11 - 1
packages/sdk/src/resources/config.ts

@@ -305,10 +305,20 @@ export interface KeybindsConfig {
   messages_previous: string;
 
   /**
-   * Revert message
+   * Redo message
+   */
+  messages_redo: string;
+
+  /**
+   * @deprecated use messages_undo. Revert message
    */
   messages_revert: string;
 
+  /**
+   * Undo message
+   */
+  messages_undo: string;
+
   /**
    * List available models
    */

+ 1 - 0
packages/sdk/src/resources/index.ts

@@ -71,6 +71,7 @@ export {
   type SessionSummarizeResponse,
   type SessionChatParams,
   type SessionInitParams,
+  type SessionRevertParams,
   type SessionSummarizeParams,
 } from './session';
 export {

+ 22 - 1
packages/sdk/src/resources/session.ts

@@ -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,
   };
 }

+ 29 - 0
packages/sdk/tests/api-resources/session.test.ts

@@ -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');

+ 18 - 5
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)
 }
 

+ 68 - 0
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 {

+ 13 - 4
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 != "" {

+ 39 - 11
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":

+ 236 - 4
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{}

+ 12 - 1
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
 	}

+ 4 - 4
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

+ 2 - 0
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

+ 7 - 1
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

+ 35 - 2
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"`

+ 51 - 0
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"

+ 2 - 0
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: