Skip to content

Commit dbb7ef9

Browse files
committed
feat(renderer): make undo/redo work
1 parent 35248e7 commit dbb7ef9

File tree

12 files changed

+5978
-32
lines changed

12 files changed

+5978
-32
lines changed

lua/opencode/api.lua

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -582,17 +582,13 @@ function M.undo()
582582
return
583583
end
584584

585-
-- ui.render_output(true)
586585
state.api_client
587586
:revert_message(state.active_session.id, {
588587
messageID = last_user_message.info.id,
589588
})
590589
:and_then(function(response)
591-
state.active_session.revert = response.revert
592590
vim.schedule(function()
593-
-- FIXME: shouldn't require a full re-render
594-
vim.notify('Last message undone successfully', vim.log.levels.INFO)
595-
require('opencode.ui.renderer').render_full_session()
591+
vim.cmd('checktime')
596592
end)
597593
end)
598594
:catch(function(err)
@@ -612,11 +608,8 @@ function M.redo()
612608
state.api_client
613609
:unrevert_messages(state.active_session.id)
614610
:and_then(function(response)
615-
state.active_session.revert = response.revert
616611
vim.schedule(function()
617-
-- FIXME: shouldn't require a full re-render
618-
vim.notify('Last message reverted successfully', vim.log.levels.INFO)
619-
require('opencode.ui.renderer').render_full_session()
612+
vim.cmd('checktime')
620613
end)
621614
end)
622615
:catch(function(err)

lua/opencode/session.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ end
150150

151151
---Get messages for a session
152152
---@param session Session
153-
---@return Promise<{info: MessageInfo, parts: MessagePart[]}[]?>
153+
---@return Promise<OpencodeMessage[]?>
154154
function M.get_messages(session)
155155
local state = require('opencode.state')
156156
if not session then

lua/opencode/types.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,3 +423,8 @@
423423
---@field desc string|nil Description of the command
424424
---@field fn fun(args:string[]):nil Function to execute the command
425425
---@field args boolean Whether the command accepts arguments
426+
427+
---@class OpencodeRevertSummary
428+
---@field messages number Number of messages reverted
429+
---@field tool_calls number Number of tool calls reverted
430+
---@field files table<string, {additions: number, deletions: number}> Summary of file changes reverted

lua/opencode/ui/formatter.lua

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,15 @@ function M._calculate_revert_stats(messages, revert_index, revert_info)
107107
end
108108

109109
---Format the revert callout with statistics
110-
---@param output Output Output object to write to
111-
---@param stats {messages: number, tool_calls: number, files: table<string, {additions: number, deletions: number}>}
112-
function M._format_revert_message(output, stats)
110+
---@param session_data OpencodeMessage[] All messages in the session
111+
---@param start_idx number Index of the message where revert occurred
112+
function M._format_revert_message(session_data, start_idx)
113+
local output = Output.new()
114+
local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)
113115
local message_text = stats.messages == 1 and 'message' or 'messages'
114116
local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'
115117

118+
output:add_lines(M.separator)
116119
output:add_line(
117120
string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)
118121
)
@@ -146,6 +149,7 @@ function M._format_revert_message(output, stats)
146149
end
147150
end
148151
end
152+
return output
149153
end
150154

151155
---@param output Output Output object to write to

lua/opencode/ui/renderer.lua

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,39 +106,36 @@ function M.render_full_session()
106106

107107
if config.debug.enabled then
108108
-- TODO: I want to track full renders for now, remove at some point
109-
vim.notify('rendering full session\n' .. debug.traceback(), vim.log.levels.WARN)
109+
-- vim.notify('rendering full session\n' .. debug.traceback(), vim.log.levels.WARN)
110110
end
111111

112112
fetch_session():and_then(M._render_full_session_data)
113113
end
114114

115115
function M._render_full_session_data(session_data)
116116
M.reset()
117+
local revert_index = nil
117118

118119
for i, msg in ipairs(session_data) do
119120
-- output:add_lines(M.separator)
120121
-- state.current_message = msg
121122

122123
if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then
123-
---@type {messages: number, tool_calls: number, files: table<string, {additions: number, deletions: number}>}
124-
local revert_stats = M._calculate_revert_stats(state.messages, i, state.active_session.revert)
125-
local output = require('opencode.ui.output'):new()
126-
formatter._format_revert_message(output, revert_stats)
127-
M.render_output(output)
128-
129-
-- FIXME: how does reverting work? why is it breaking out of the message reading loop?
130-
break
124+
revert_index = i
131125
end
132126

133127
-- only pass in the info so, the parts will be processed as part of the loop
134128
-- TODO: remove part processing code in formatter
135-
M.on_message_updated({ info = msg.info })
129+
M.on_message_updated({ info = msg.info }, revert_index)
136130

137131
for j, part in ipairs(msg.parts or {}) do
138-
M.on_part_updated({ part = part })
132+
M.on_part_updated({ part = part }, revert_index)
139133
end
140134
end
141135

136+
if revert_index then
137+
M._write_formatted_data(formatter._format_revert_message(state.messages, revert_index))
138+
end
142139
M._scroll_to_bottom()
143140
end
144141

@@ -344,7 +341,8 @@ end
344341
---Event handler for message.updated events
345342
---Creates new message or updates existing message info
346343
---@param message {info: MessageInfo} Event properties
347-
function M.on_message_updated(message)
344+
---@param revert_index? integer Revert index in session, if applicable
345+
function M.on_message_updated(message, revert_index)
348346
---@type OpencodeMessage
349347
if not message or not message.info or not message.info.id or not message.info.sessionID then
350348
return
@@ -358,14 +356,22 @@ function M.on_message_updated(message)
358356
local rendered_message = M._render_state:get_message(message.info.id)
359357
local found_msg = rendered_message and rendered_message.message
360358

359+
if revert_index then
360+
if not found_msg then
361+
table.insert(state.messages, message)
362+
end
363+
M._render_state:set_message(message.info.id, message, 0, 0)
364+
return
365+
end
366+
361367
if found_msg then
362368
-- see if an error was added (or removed). have to check before we set
363369
-- found_msg.info = message.info below
364370
local rerender_message = not vim.deep_equal(found_msg.info.error, message.info.error)
365371

366372
found_msg.info = message.info
367373

368-
if rerender_message then
374+
if rerender_message and not revert_index then
369375
local header_data = formatter.format_message_header(found_msg)
370376
M._replace_message_in_buffer(message.info.id, header_data)
371377
end
@@ -380,7 +386,6 @@ function M.on_message_updated(message)
380386
end
381387

382388
state.current_message = message
383-
384389
if message.info.role == 'user' then
385390
state.last_user_message = message
386391
end
@@ -394,7 +399,8 @@ end
394399
---Event handler for message.part.updated events
395400
---Inserts new parts or replaces existing parts in buffer
396401
---@param properties {part: MessagePart} Event properties
397-
function M.on_part_updated(properties)
402+
---@param revert_index? integer Revert index in session, if applicable
403+
function M.on_part_updated(properties, revert_index)
398404
if not properties or not properties.part then
399405
return
400406
end
@@ -445,6 +451,10 @@ function M.on_part_updated(properties)
445451

446452
local formatted = formatter.format_part(part, message.info.role)
447453

454+
if revert_index and is_new_part then
455+
return
456+
end
457+
448458
if is_new_part then
449459
M._insert_part_to_buffer(part.id, formatted)
450460
else
@@ -533,6 +543,10 @@ end
533543
---@param properties {info: Session}
534544
function M.on_session_updated(properties)
535545
require('opencode.ui.topbar').render()
546+
if not vim.deep_equal(state.active_session.revert, properties.info.revert) then
547+
state.active_session.revert = properties.info.revert
548+
M._render_full_session_data(state.messages)
549+
end
536550
end
537551

538552
---Event handler for session.error events

lua/opencode/ui/session_picker.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ local function format_session(session)
1818
table.insert(parts, modified)
1919
end
2020

21+
table.insert(parts, 'ID: ' .. (session.id or 'N/A'))
2122
return table.concat(parts, ' ~ ')
2223
end
2324

lua/opencode/ui/ui.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ function M.select_session(sessions, cb)
199199
vim.ui.select(sessions, {
200200
prompt = '',
201201
format_item = function(session)
202-
local parts = {}
202+
local parts = { { session.id } }
203203

204204
if session.description then
205205
table.insert(parts, session.description)

tests/data/revert.expected.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"actions":[{"range":{"to":55,"from":55},"text":"[R]evert file","display_line":55,"key":"R","args":["c410b2b4024de020aea223c5248eec89216de53f"],"type":"diff_revert_selected_file"},{"range":{"to":55,"from":55},"text":"Revert [A]ll","display_line":55,"key":"A","args":["c410b2b4024de020aea223c5248eec89216de53f"],"type":"diff_revert_all"},{"range":{"to":55,"from":55},"text":"[D]iff","display_line":55,"key":"D","args":["c410b2b4024de020aea223c5248eec89216de53f"],"type":"diff_open"}],"extmarks":[[1,2,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-19 17:50:43)","OpencodeHint"],[" [msg_9fd985573001fk1Xlot7uyDgTo]","OpencodeHint"]],"virt_text_win_col":-3,"priority":10}],[2,3,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[3,4,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[4,5,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[5,6,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[6,9,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2025-10-19 17:50:44)","OpencodeHint"],[" [msg_9fd985a4d001wOX3Op7CpFiCTq]","OpencodeHint"]],"virt_text_win_col":-3,"priority":10}],[7,27,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-19 17:50:57)","OpencodeHint"],[" [msg_9fd988c92001w0IZCVPQsN6xa9]","OpencodeHint"]],"virt_text_win_col":-3,"priority":10}],[8,28,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[9,29,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_win_col":-3,"priority":4096}],[10,32,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2025-10-19 17:50:57)","OpencodeHint"],[" [msg_9fd988ca7001lgaGttpI4YeGSA]","OpencodeHint"]],"virt_text_win_col":-3,"priority":10}],[11,38,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[12,39,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[13,40,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[14,41,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[15,42,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[16,43,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[17,44,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[18,45,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[19,46,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[20,47,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[21,48,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[22,49,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[23,50,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[24,51,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[25,52,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"virt_text_win_col":-1,"priority":4096}],[26,57,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2025-10-19 17:50:59)","OpencodeHint"],[" [msg_9fd98942d001elqd2sd8CZeOoA]","OpencodeHint"]],"virt_text_win_col":-3,"priority":10}],[27,67,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text":[["-20","OpencodeDiffDeleteText"]],"virt_text_win_col":11,"priority":1000}]],"lines":["","----","","","write 10 random words","","[poem.md](poem.md)","","----","","","Here are 10 random words:","","1. Lantern ","2. Whisper ","3. Velvet ","4. Orbit ","5. Timber ","6. Quiver ","7. Mosaic ","8. Ember ","9. Spiral ","10. Glimmer","","Let me know if you need them in a specific format or want to use them in a file!","","----","","","write 10 random words to the file","","----","","","I will write 10 random words to poem.md, each on a new line.","","Proceeding to update the file now.","","** write** `poem.md`","","```md","Lantern","Whisper","Velvet","Orbit","Timber","Quiver","Mosaic","Ember","Spiral","Glimmer","","```","","**󰻛 Created Snapshot** `c410b2b4`","","----","","","The file poem.md has been updated with 10 random words, each on a new line. Task complete! If you need anything else, let me know.","","----","","> 2 messages reverted, 4 tool calls reverted",">","> type `/redo` to restore.",""," poem.md: -20"],"timestamp":1760961403}

0 commit comments

Comments
 (0)