mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 03:04:21 +01:00
basic undo feature (#1268)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Andrew Joslin <andrew@ajoslin.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Tobias Walle <9933601+tobias-walle@users.noreply.github.com>
This commit is contained in:
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user