From d2f9e24f26a7fd44f77bbbe03dedc1a04e283e5e Mon Sep 17 00:00:00 2001 From: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:11:06 -0500 Subject: [PATCH] wip(tui): undo/redo --- packages/opencode/src/config/config.ts | 9 +- packages/opencode/src/server/server.ts | 74 +++++- packages/opencode/src/session/index.ts | 9 +- packages/tui/internal/app/app.go | 23 +- packages/tui/internal/app/prompt.go | 68 +++++ packages/tui/internal/commands/command.go | 17 +- .../tui/internal/components/chat/editor.go | 50 +++- .../tui/internal/components/chat/messages.go | 234 +++++++++++++++++- packages/tui/internal/tui/tui.go | 13 +- packages/tui/sdk/.stats.yml | 8 +- packages/tui/sdk/api.md | 3 +- packages/tui/sdk/app.go | 18 -- packages/tui/sdk/config.go | 11 +- packages/tui/sdk/option/requestoption.go | 9 +- packages/tui/sdk/session.go | 38 ++- packages/tui/sdk/session_test.go | 54 ++++ stainless.yml | 2 + 17 files changed, 573 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7ec4b3141..b763a0902 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("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), session_share: z.string().optional().default("s").describe("Share current session"), - session_unshare: z.string().optional().default("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("c").describe("Compact the session"), tool_details: z.string().optional().default("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("p").describe("Toggle layout"), messages_copy: z.string().optional().default("y").describe("Copy message"), - messages_revert: z.string().optional().default("r").describe("Revert message"), + messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"), + messages_undo: z.string().optional().default("u").describe("Undo message"), + messages_redo: z.string().optional().default("r").describe("Redo message"), app_exit: z.string().optional().default("ctrl+c,q").describe("Exit the application"), }) .strict() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4e6ebfbb3..191520895 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -57,15 +57,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", @@ -459,6 +464,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 38863cc9f..0369f8d3e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -972,7 +972,14 @@ export namespace Session { } } - export async function revert(input: { sessionID: string; messageID: string; partID?: string }) { + export const RevertInput = z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message"), + partID: Identifier.schema("part").optional(), + }) + export type RevertInput = z.infer + + export async function revert(input: RevertInput) { const all = await messages(input.sessionID) let lastUser: MessageV2.User | undefined let lastSnapshot: MessageV2.SnapshotPart | undefined diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 976d6efa8..c70c6d0ed 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -53,6 +53,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 @@ -175,6 +182,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 @@ -184,11 +201,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 158951d84..debca432b 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" @@ -106,6 +107,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 dde49e824..18719e718 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -118,7 +118,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" ) @@ -328,9 +329,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("y"), }, { - Name: MessagesRevertCommand, - Description: "revert message", + Name: MessagesUndoCommand, + Description: "undo last message", + Keybindings: parseBindings("u"), + Trigger: []string{"undo"}, + }, + { + Name: MessagesRedoCommand, + Description: "redo message", Keybindings: parseBindings("r"), + Trigger: []string{"redo"}, }, { Name: AppExitCommand, @@ -345,7 +353,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 dbd4f3dbb..67dbcca47 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 { @@ -120,10 +122,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) text = strings.ReplaceAll(text, "\\", "") @@ -626,21 +652,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++ { @@ -657,6 +676,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 ff7c33517..9c4efed5d 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" "strings" @@ -10,6 +11,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" @@ -30,6 +32,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 { @@ -160,6 +164,18 @@ 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 { @@ -204,7 +220,6 @@ type renderCompleteMsg struct { } func (m *messagesComponent) renderView() tea.Cmd { - if m.rendering { slog.Debug("pending render, skipping") m.dirty = true @@ -232,12 +247,26 @@ func (m *messagesComponent) renderView() tea.Cmd { width := m.width // always use full width + reverted := false + revertedMessageCount := 0 + revertedToolCount := 0 for _, message := range m.app.Messages { var content string var cached bool 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: @@ -312,10 +341,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:] @@ -394,6 +431,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) @@ -460,7 +501,7 @@ func (m *messagesComponent) renderView() tea.Cmd { } } - if error != "" { + if error != "" && !reverted { error = styles.NewStyle().Width(width - 6).Render(error) error = renderContentBlock( m.app, @@ -479,6 +520,40 @@ 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 = renderContentBlock( + m.app, + content, + width, + WithBorderColor(t.BackgroundPanel()), + ) + blocks = append(blocks, content) + } + final := []string{} clipboard := []string{} var selection *selection @@ -510,7 +585,11 @@ func (m *messagesComponent) renderView() tea.Cmd { middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ") suffix := ansi.Cut(line, left+len(middle), width) clipboard = append(clipboard, middle) - line = prefix + styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Render(ansi.Strip(middle)) + suffix + line = prefix + styles.NewStyle(). + Background(t.Accent()). + Foreground(t.BackgroundPanel()). + Render(ansi.Strip(middle)) + + suffix } final = append(final, line) } @@ -761,6 +840,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 6ff8f9282..b5dc29b82 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -463,6 +463,10 @@ func (a appModel) 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 @@ -1005,7 +1009,14 @@ func (a appModel) 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 3aa07cb68..241e0d705 100644 --- a/packages/tui/sdk/.stats.yml +++ b/packages/tui/sdk/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 22 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-e7f4ac9b5afd5c6db4741a27b5445167808b0a3b7c36dfd525bfb3446a11a253.yml -openapi_spec_hash: 3e7b367a173d6de7924f35a41ac6b5a5 -config_hash: 6d56a7ca0d6ed899ecdb5c053a8278ae +configured_endpoints: 24 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-e4d6c6107c344f07223707185edd88aec4d15be1b57298c697fb83e43e7fd741.yml +openapi_spec_hash: eec0031eab68bb9868fc7aac364d701a +config_hash: 8bedc9f1bc45691bd29b7e5162f4984d diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md index 242fadc99..a7a558b69 100644 --- a/packages/tui/sdk/api.md +++ b/packages/tui/sdk/api.md @@ -19,7 +19,6 @@ Methods: Response Types: - opencode.App -- opencode.LogLevel - opencode.Mode - opencode.Model - opencode.Provider @@ -115,6 +114,8 @@ Methods: - client.Session.Chat(ctx context.Context, id string, body opencode.SessionChatParams) (opencode.AssistantMessage, error) - client.Session.Init(ctx context.Context, id string, body opencode.SessionInitParams) (bool, error) - client.Session.Messages(ctx context.Context, id string) ([]opencode.SessionMessagesResponse, error) +- client.Session.Revert(ctx context.Context, id string, body opencode.SessionRevertParams) (opencode.Session, error) - client.Session.Share(ctx context.Context, id string) (opencode.Session, error) - client.Session.Summarize(ctx context.Context, id string, body opencode.SessionSummarizeParams) (bool, error) +- client.Session.Unrevert(ctx context.Context, id string) (opencode.Session, error) - client.Session.Unshare(ctx context.Context, id string) (opencode.Session, error) diff --git a/packages/tui/sdk/app.go b/packages/tui/sdk/app.go index aa47e83b2..407de0617 100644 --- a/packages/tui/sdk/app.go +++ b/packages/tui/sdk/app.go @@ -145,24 +145,6 @@ func (r appTimeJSON) RawJSON() string { return r.raw } -// Log level -type LogLevel string - -const ( - LogLevelDebug LogLevel = "DEBUG" - LogLevelInfo LogLevel = "INFO" - LogLevelWarn LogLevel = "WARN" - LogLevelError LogLevel = "ERROR" -) - -func (r LogLevel) IsKnown() bool { - switch r { - case LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError: - return true - } - return false -} - type Mode struct { Name string `json:"name,required"` Tools map[string]bool `json:"tools,required"` diff --git a/packages/tui/sdk/config.go b/packages/tui/sdk/config.go index b824e5788..96759fc47 100644 --- a/packages/tui/sdk/config.go +++ b/packages/tui/sdk/config.go @@ -57,8 +57,6 @@ type Config struct { Keybinds KeybindsConfig `json:"keybinds"` // @deprecated Always uses stretch layout. Layout ConfigLayout `json:"layout"` - // Minimum log level to write to log files - LogLevel LogLevel `json:"log_level"` // MCP (Model Context Protocol) server configurations Mcp map[string]ConfigMcp `json:"mcp"` // Modes configuration, see https://opencode.ai/docs/modes @@ -90,7 +88,6 @@ type configJSON struct { Instructions apijson.Field Keybinds apijson.Field Layout apijson.Field - LogLevel apijson.Field Mcp apijson.Field Mode apijson.Field Model apijson.Field @@ -513,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 @@ -568,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/option/requestoption.go b/packages/tui/sdk/option/requestoption.go index 313552e9b..68478066b 100644 --- a/packages/tui/sdk/option/requestoption.go +++ b/packages/tui/sdk/option/requestoption.go @@ -27,14 +27,15 @@ type RequestOption = requestconfig.RequestOption // For security reasons, ensure that the base URL is trusted. func WithBaseURL(base string) RequestOption { u, err := url.Parse(base) + if err == nil && u.Path != "" && !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { if err != nil { - return fmt.Errorf("requestoption: WithBaseURL failed to parse url %s\n", err) + return fmt.Errorf("requestoption: WithBaseURL failed to parse url %s", err) } - if u.Path != "" && !strings.HasSuffix(u.Path, "/") { - u.Path += "/" - } r.BaseURL = u return nil }) diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go index bdee22aea..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 @@ -1954,6 +1978,7 @@ type SessionChatParams struct { ProviderID param.Field[string] `json:"providerID,required"` MessageID param.Field[string] `json:"messageID"` Mode param.Field[string] `json:"mode"` + Tools param.Field[map[string]bool] `json:"tools"` } func (r SessionChatParams) MarshalJSON() (data []byte, err error) { @@ -2009,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 b96a98b9d..807f19563 100644 --- a/packages/tui/sdk/session_test.go +++ b/packages/tui/sdk/session_test.go @@ -131,6 +131,9 @@ func TestSessionChatWithOptionalParams(t *testing.T) { ProviderID: opencode.F("providerID"), MessageID: opencode.F("msg"), Mode: opencode.F("mode"), + Tools: opencode.F(map[string]bool{ + "foo": true, + }), }, ) if err != nil { @@ -194,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" @@ -245,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 48941320f..785502ff9 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 settings: disable_mock_tests: true