@@ -32,6 +32,19 @@ function M.format_session(session)
3232 M .output :add_line (' ' )
3333 M .output :add_line (' ' )
3434
35+ -- Backfill assistant_mode for all assistant messages once so names remain stable
36+ local last_seen_mode = state .current_mode
37+ for _ , amsg in ipairs (state .messages ) do
38+ if amsg .role == ' assistant' then
39+ if amsg .assistant_mode and amsg .assistant_mode ~= ' ' then
40+ last_seen_mode = amsg .assistant_mode
41+ else
42+ amsg .assistant_mode = last_seen_mode or state .current_mode or ' assistant'
43+ last_seen_mode = amsg .assistant_mode
44+ end
45+ end
46+ end
47+
3548 for i , msg in ipairs (state .messages ) do
3649 M .output :add_lines (M .separator )
3750 state .current_message = msg
@@ -49,6 +62,7 @@ function M.format_session(session)
4962 end
5063
5164 if session .revert and session .revert .messageID == msg .id then
65+ --- @type { messages : number , tool_calls : number , files : table<string , { additions : number , deletions : number } >}
5266 local revert_stats = M ._calculate_revert_stats (state .messages , i , session .revert )
5367 M ._format_revert_message (revert_stats )
5468 break
@@ -61,7 +75,7 @@ function M.format_session(session)
6175 M .output :add_metadata (M ._current )
6276
6377 if part .type == ' text' and part .text then
64- if msg .role == ' user' and not part .synthetic = = true then
78+ if msg .role == ' user' and part .synthetic ~ = true then
6579 state .last_user_message = msg
6680 M ._format_user_message (vim .trim (part .text ), msg )
6781 elseif msg .role == ' assistant' then
@@ -85,12 +99,18 @@ function M.format_session(session)
8599end
86100
87101--- @param line number Buffer line number
88- --- @return { message : Message , part : MessagePart , type : string , msg_idx : number , part_idx : number }| nil
102+ --- @return { message : Message , part : MessagePart , msg_idx : number , part_idx : number }| nil
89103function M .get_message_at_line (line )
90104 local metadata = M .output :get_nearest_metadata (line )
91105 if metadata and metadata .msg_idx and metadata .part_idx then
92- local msg = state .messages [metadata .msg_idx ]
106+ local msg = state .messages and state .messages [metadata .msg_idx ]
107+ if not msg or not msg .parts then
108+ return nil
109+ end
93110 local part = msg .parts [metadata .part_idx ]
111+ if not part then
112+ return nil
113+ end
94114 return {
95115 message = msg ,
96116 part = part ,
109129--- @param messages Message[] All messages in the session
110130--- @param revert_index number Index of the message where revert occurred
111131--- @param revert_info SessionRevertInfo Revert information
112- --- @return { messages : number , tool_calls : number , files : { additions : number , deletions : number } [] }
132+ --- @return { messages : number , tool_calls : number , files : table<string , {additions : number , deletions : number } > }
113133function M ._calculate_revert_stats (messages , revert_index , revert_info )
114134 local stats = {
115135 messages = 0 ,
@@ -185,10 +205,10 @@ function M._format_revert_message(stats)
185205 end
186206 if # file_diff > 0 then
187207 local line_str = string.format (icons .get (' file' ) .. ' %s: %s' , file , table.concat (file_diff , ' ' ))
188- local line_idx = M .output :add_line (line_str )
189- local col = # (' ' .. file .. ' : ' )
208+ local line_idx = M .output :add_line (line_str ) --- @type number
209+ local col = # (' ' .. file .. ' : ' ) --- @type number
190210 for _ , diff in ipairs (file_diff ) do
191- local hl_group = diff :sub (1 , 1 ) == ' +' and ' OpencodeDiffAddText' or ' OpencodeDiffDeleteText'
211+ local hl_group = diff :sub (1 , 1 ) == ' +' and ' OpencodeDiffAddText' or ' OpencodeDiffDeleteText' --- @type string
192212 M .output :add_extmark (line_idx , {
193213 virt_text = { { diff , hl_group } },
194214 virt_text_pos = ' inline' ,
@@ -206,23 +226,32 @@ function M._format_patch(part)
206226 local restore_points = snapshot .get_restore_points_by_parent (part .hash )
207227 M .output :add_empty_line ()
208228 M ._format_action (icons .get (' snapshot' ) .. ' **Created Snapshot**' , vim .trim (part .hash :sub (1 , 8 )))
229+ local snapshot_header_line = M .output :get_line_count ()
230+
231+ -- Anchor all snapshot-level actions to the snapshot header line
209232 M .output :add_action ({
210233 text = ' [R]evert file' ,
211234 type = ' diff_revert_selected_file' ,
212235 args = { part .hash },
213236 key = ' R' ,
237+ display_line = snapshot_header_line ,
238+ range = { from = snapshot_header_line , to = snapshot_header_line },
214239 })
215240 M .output :add_action ({
216241 text = ' Revert [A]ll' ,
217242 type = ' diff_revert_all' ,
218243 args = { part .hash },
219244 key = ' A' ,
245+ display_line = snapshot_header_line ,
246+ range = { from = snapshot_header_line , to = snapshot_header_line },
220247 })
221248 M .output :add_action ({
222249 text = ' [D]iff' ,
223250 type = ' diff_open' ,
224251 args = { part .hash },
225252 key = ' D' ,
253+ display_line = snapshot_header_line ,
254+ range = { from = snapshot_header_line , to = snapshot_header_line },
226255 })
227256
228257 if # restore_points > 0 then
@@ -235,17 +264,22 @@ function M._format_patch(part)
235264 util .time_ago (restore_point .created_at )
236265 )
237266 )
267+ local restore_line = M .output :get_line_count () --- @type number
238268 M .output :add_action ({
239269 text = ' Restore [A]ll' ,
240270 type = ' diff_restore_snapshot_all' ,
241271 args = { part .hash },
242272 key = ' A' ,
273+ display_line = restore_line ,
274+ range = { from = restore_line , to = restore_line },
243275 })
244276 M .output :add_action ({
245277 text = ' [R]estore file' ,
246278 type = ' diff_restore_snapshot_file' ,
247279 args = { part .hash },
248280 key = ' R' ,
281+ display_line = restore_line ,
282+ range = { from = restore_line , to = restore_line },
249283 })
250284 end
251285 end
@@ -258,6 +292,7 @@ function M._format_error(message)
258292end
259293
260294--- @param message Message
295+ --- @param msg_idx number Message index in the session
261296function M ._format_message_header (message , msg_idx )
262297 local role = message .role or ' unknown'
263298 local icon = message .role == ' user' and icons .get (' header_user' ) or icons .get (' header_assistant' )
@@ -270,14 +305,28 @@ function M._format_message_header(message, msg_idx)
270305
271306 M .output :add_empty_line ()
272307 M .output :add_metadata ({ msg_idx = msg_idx , part_idx = 1 , role = role , type = ' header' })
308+
309+ -- Use the assistant_mode stored on the message only (stable label)
310+ local display_name
311+ if role == ' assistant' then
312+ local mode = message .assistant_mode
313+ if mode and mode ~= ' ' then
314+ display_name = mode :upper ()
315+ else
316+ display_name = ' ASSISTANT'
317+ end
318+ else
319+ display_name = role :upper ()
320+ end
321+
273322 M .output :add_extmark (M .output :get_line_count (), {
274323 virt_text = {
275324 { icon , role_hl },
276325 { ' ' },
277- { role : upper () , role_hl },
326+ { display_name , role_hl },
278327 { model_text , ' OpencodeHint' },
279- { time_text , ' OpenCodeHint ' },
280- { debug_text , ' OpenCodeHint ' },
328+ { time_text , ' OpencodeHint ' },
329+ { debug_text , ' OpencodeHint ' },
281330 },
282331 virt_text_win_col = - 3 ,
283332 priority = 10 ,
@@ -287,9 +336,11 @@ function M._format_message_header(message, msg_idx)
287336end
288337
289338--- @param callout string Callout type (e.g. , ' ERROR' , ' TODO' )
339+ --- @param text string Callout text content
340+ --- @param title ? string Optional title for the callout
290341function M ._format_callout (callout , text , title )
291342 title = title and title .. ' ' or ' '
292- local win_width = vim .api .nvim_win_get_width (state .windows .output_win )
343+ local win_width = ( state . windows and state . windows . output_win and vim .api .nvim_win_is_valid ( state . windows . output_win )) and vim . api . nvim_win_get_width (state .windows .output_win ) or config . ui . window_width or 80
293344 if # text > win_width - 4 then
294345 local ok , substituted = pcall (vim .fn .substitute , text , ' \v (.{' .. (win_width - 8 ) .. ' })' , ' \1 \n ' , ' g' )
295346 text = ok and substituted or text
@@ -315,7 +366,7 @@ function M._format_user_message(text, message)
315366 context = context_module .extract_from_opencode_message (message )
316367 end
317368
318- local start_line = M .output :get_line_count () - 1
369+ local start_line = M .output :get_line_count () - 1 --- @type number
319370
320371 M .output :add_empty_line ()
321372 M .output :add_lines (vim .split (context .prompt , ' \n ' ))
@@ -333,7 +384,7 @@ function M._format_user_message(text, message)
333384 M .output :add_line (string.format (' [%s](%s)' , path , context .current_file ))
334385 end
335386
336- local end_line = M .output :get_line_count ()
387+ local end_line = M .output :get_line_count () --- @type number
337388
338389 M ._add_vertical_border (start_line , end_line , ' OpencodeMessageRoleUser' , - 3 )
339390end
@@ -345,6 +396,7 @@ function M._format_assistant_message(text)
345396end
346397
347398--- @param type string Tool type (e.g. , ' run' , ' read' , ' edit' , etc. )
399+ --- @param value string Value associated with the action (e.g. , filename , command )
348400function M ._format_action (type , value )
349401 if not type or not value then
350402 return
@@ -473,9 +525,12 @@ function M._format_tool(part)
473525 end
474526
475527 local start_line = M .output :get_line_count () + 1
476- local input = part .state and part .state .input or {}
477- local metadata = part .state .metadata or {}
478- local output = part .state and part .state .output or ' '
528+ --- @type TaskToolInput | BashToolInput | FileToolInput | TodoToolInput | GlobToolInput | GrepToolInput | WebFetchToolInput | ListToolInput
529+ local input = (part .state and part .state .input ) or {}
530+ --- @type ToolMetadataBase | TaskToolMetadata | WebFetchToolMetadata | BashToolMetadata | FileToolMetadata | GlobToolMetadata | GrepToolMetadata | ListToolMetadata
531+ local metadata = (part .state and part .state .metadata ) or {}
532+ --- @type string
533+ local output = (part .state and part .state .output ) or ' '
479534
480535 if tool == ' bash' then
481536 M ._format_bash_tool (input --[[ @as BashToolInput]] , metadata --[[ @as BashToolMetadata]] )
@@ -503,7 +558,7 @@ function M._format_tool(part)
503558
504559 M .output :add_empty_line ()
505560
506- local end_line = M .output :get_line_count ()
561+ local end_line = M .output :get_line_count () --- @type number
507562 if end_line - start_line > 1 then
508563 M ._add_vertical_border (start_line , end_line - 1 , ' OpencodeToolBorder' , - 1 )
509564 end
@@ -532,7 +587,7 @@ function M._format_task_tool(input, metadata, output)
532587 end
533588 end
534589
535- local end_line = M .output :get_line_count ()
590+ local end_line = M .output :get_line_count () --- @type number
536591 M .output :add_action ({
537592 text = ' [S]elect Child Session' ,
538593 type = ' select_child_session' ,
@@ -569,7 +624,7 @@ function M._format_diff(code, file_type)
569624 return {
570625 end_col = 0 ,
571626 end_row = line_idx ,
572- virt_text = { { first_char , { hl_group } } },
627+ virt_text = { { first_char , hl_group } },
573628 hl_group = hl_group ,
574629 hl_eol = true ,
575630 priority = 5000 ,
0 commit comments