Skip to content

Commit 4fe9c99

Browse files
committed
fix(ui): correct action & task line offsets
Adjust indexing used when anchoring actions and task ranges so actions and extmarks align with their rendered lines: - formatter.add_action: use (line or output:get_line_count()) - 1 - formatter/tools/task: set display_line = start_line and range = { from = start_line + 1, to = end_line + 1 } - renderer/buffer: pass line_start to add_actions instead of line_start + 1 - renderer: avoid inserting synthetic revert message into state.messages; notify via events only Add tests: - unit tests for formatter anchoring and assistant-mode fallback - replay test asserting a single synthetic revert message is produced Fixes off-by-one rendering/anchor issues and adds tests to prevent regressions.
1 parent acd1575 commit 4fe9c99

6 files changed

Lines changed: 156 additions & 5 deletions

File tree

lua/opencode/ui/formatter.lua

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

102102
local function add_action(output, text, action_type, args, key, line)
103103
-- actions use api-indexing (e.g. 0 indexed)
104-
line = (line or output:get_line_count()) - 2
104+
line = (line or output:get_line_count()) - 1
105105
output:add_action({
106106
text = text,
107107
type = action_type,

lua/opencode/ui/formatter/tools/task.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ function M.format(output, part, get_child_parts)
7979
type = 'select_child_session',
8080
args = {},
8181
key = 'S',
82-
display_line = start_line - 1,
83-
range = { from = start_line, to = end_line },
82+
display_line = start_line,
83+
range = { from = start_line + 1, to = end_line + 1 },
8484
})
8585
end
8686

lua/opencode/ui/renderer.lua

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ function M._render_full_session_data(session_data)
156156
},
157157
}
158158

159-
table.insert(state.messages, revert_message)
160159
events.on_message_updated(revert_message)
161160
events.on_part_updated({ part = revert_message.parts[1] })
162161
end

lua/opencode/ui/renderer/buffer.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ end
276276
local function apply_part_actions(part_id, formatted_data, line_start)
277277
if has_actions(formatted_data.actions) then
278278
ctx.render_state:clear_actions(part_id)
279-
ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start + 1)
279+
ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start)
280280
else
281281
ctx.render_state:clear_actions(part_id)
282282
end

tests/replay/renderer_spec.lua

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,36 @@ describe('renderer unit tests', function()
195195
render_stub:revert()
196196
end)
197197

198+
it('inserts a single synthetic revert message during full session render', function()
199+
local renderer = require('opencode.ui.renderer')
200+
201+
helpers.replay_setup()
202+
203+
state.session.set_active({
204+
id = 'ses_123',
205+
title = 'Session',
206+
time = { created = 1, updated = 1 },
207+
revert = { messageID = 'msg_1', snapshot = 'a', diff = '' },
208+
})
209+
210+
renderer._render_full_session_data({
211+
{
212+
info = {
213+
id = 'msg_1',
214+
role = 'assistant',
215+
sessionID = 'ses_123',
216+
},
217+
parts = {},
218+
},
219+
})
220+
221+
local revert_messages = vim.tbl_filter(function(message)
222+
return message.info and message.info.id == '__opencode_revert_message__'
223+
end, state.messages or {})
224+
225+
assert.are.equal(1, #revert_messages)
226+
end)
227+
198228
it('ignores session.updated for non-active session IDs', function()
199229
local renderer = require('opencode.ui.renderer')
200230

tests/unit/formatter_spec.lua

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local assert = require('luassert')
22
local config = require('opencode.config')
33
local formatter = require('opencode.ui.formatter')
44
local Output = require('opencode.ui.output')
5+
local state = require('opencode.state')
56

67
describe('formatter', function()
78
before_each(function()
@@ -238,4 +239,125 @@ describe('formatter', function()
238239
assert.are.equal('** grep** `*.lua eventignore` 1s', output.lines[1])
239240
assert.are.equal('Found `3` matches', output.lines[2])
240241
end)
242+
243+
it('anchors snapshot actions to the snapshot and restore lines', function()
244+
local snapshot = require('opencode.snapshot')
245+
local original_get_restore_points_by_parent = snapshot.get_restore_points_by_parent
246+
247+
snapshot.get_restore_points_by_parent = function(hash)
248+
if hash == 'abcdef123456' then
249+
return {
250+
{
251+
id = 'restore123456',
252+
created_at = 1,
253+
},
254+
}
255+
end
256+
return {}
257+
end
258+
259+
local message = {
260+
info = {
261+
id = 'msg_1',
262+
role = 'assistant',
263+
sessionID = 'ses_1',
264+
},
265+
parts = {},
266+
}
267+
268+
local part = {
269+
id = 'prt_patch_1',
270+
type = 'patch',
271+
hash = 'abcdef123456',
272+
messageID = 'msg_1',
273+
sessionID = 'ses_1',
274+
}
275+
276+
local output = formatter.format_part(part, message, true)
277+
278+
snapshot.get_restore_points_by_parent = original_get_restore_points_by_parent
279+
280+
assert.are.same({ 0, 0, 0, 1, 1 }, vim.tbl_map(function(action)
281+
return action.display_line
282+
end, output.actions))
283+
end)
284+
285+
it('falls back to current mode for assistant messages without a stamped mode', function()
286+
state.model.set_mode('build')
287+
local output = formatter.format_message_header({
288+
info = {
289+
id = 'msg_current',
290+
role = 'assistant',
291+
sessionID = 'ses_1',
292+
},
293+
parts = {},
294+
})
295+
296+
assert.are.equal('BUILD', output.extmarks[1][1].virt_text[3][1])
297+
end)
298+
299+
it('anchors task child-session action to the rendered task block', function()
300+
local message = {
301+
info = {
302+
id = 'msg_1',
303+
role = 'assistant',
304+
sessionID = 'ses_1',
305+
},
306+
parts = {},
307+
}
308+
309+
local part = {
310+
id = 'prt_task_1',
311+
type = 'tool',
312+
tool = 'task',
313+
messageID = 'msg_1',
314+
sessionID = 'ses_1',
315+
state = {
316+
status = 'completed',
317+
input = {
318+
description = 'review changes',
319+
subagent_type = 'explore',
320+
},
321+
metadata = {
322+
sessionId = 'ses_child',
323+
},
324+
time = {
325+
start = 1,
326+
['end'] = 2,
327+
},
328+
},
329+
}
330+
331+
local child_parts = {
332+
{
333+
id = 'prt_child_1',
334+
type = 'tool',
335+
tool = 'read',
336+
messageID = 'msg_child_1',
337+
sessionID = 'ses_child',
338+
state = {
339+
status = 'completed',
340+
input = {
341+
filePath = '/tmp/project',
342+
},
343+
},
344+
},
345+
}
346+
347+
local output = formatter.format_part(part, message, true, function(session_id)
348+
if session_id == 'ses_child' then
349+
return child_parts
350+
end
351+
return nil
352+
end)
353+
354+
assert.are.same({
355+
text = '[S]elect Child Session',
356+
type = 'select_child_session',
357+
args = {},
358+
key = 'S',
359+
display_line = 1,
360+
range = { from = 2, to = 5 },
361+
}, output.actions[1])
362+
end)
241363
end)

0 commit comments

Comments
 (0)