Skip to content

Commit 51922dd

Browse files
committed
feat(renderer): pin permission/question displays to bottom and add tests
Add pinned_bottom_message_ids and is_pinned_bottom_message to the buffer renderer so permission-display-message and question-display-message are kept pinned below later messages when rendering. Update get_message_insert_line logic to respect pinned messages and to prefer rendered pinned positions when available.
1 parent 53d5864 commit 51922dd

3 files changed

Lines changed: 148 additions & 4 deletions

File tree

lua/opencode/ui/renderer/buffer.lua

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ local output_window = require('opencode.ui.output_window')
44

55
local M = {}
66

7+
local pinned_bottom_message_ids = {
8+
['permission-display-message'] = true,
9+
['question-display-message'] = true,
10+
}
11+
12+
---@param message_id string|nil
13+
---@return boolean
14+
local function is_pinned_bottom_message(message_id)
15+
return message_id ~= nil and pinned_bottom_message_ids[message_id] == true
16+
end
17+
718
---@param extmarks table<number, OutputExtmark[]|fun(): OutputExtmark>[]|table<number, OutputExtmark[]>|nil
819
---@return boolean
920
local function has_extmarks(extmarks)
@@ -259,19 +270,48 @@ local function get_message_insert_line(message_id)
259270
end
260271

261272
if not message_index then
273+
if is_pinned_bottom_message(message_id) then
274+
return append_at
275+
end
276+
277+
for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do
278+
local pinned_rendered = ctx.render_state:get_message(pinned_message_id)
279+
if pinned_rendered and pinned_rendered.line_start then
280+
return pinned_rendered.line_start
281+
end
282+
end
283+
284+
return append_at
285+
end
286+
287+
if is_pinned_bottom_message(message_id) then
262288
return append_at
263289
end
264290

265291
for i = message_index + 1, #messages do
266292
local next_message = messages[i]
267293
if next_message and next_message.info and next_message.info.id then
294+
if is_pinned_bottom_message(next_message.info.id) then
295+
local next_rendered = ctx.render_state:get_message(next_message.info.id)
296+
if next_rendered and next_rendered.line_start then
297+
return next_rendered.line_start
298+
end
299+
end
300+
268301
local next_rendered = ctx.render_state:get_message(next_message.info.id)
269302
if next_rendered and next_rendered.line_start then
270303
return next_rendered.line_start
271304
end
272305
end
273306
end
274307

308+
for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do
309+
local pinned_rendered = ctx.render_state:get_message(pinned_message_id)
310+
if pinned_rendered and pinned_rendered.line_start then
311+
return pinned_rendered.line_start
312+
end
313+
end
314+
275315
return append_at
276316
end
277317

lua/opencode/ui/renderer/events.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,14 @@ end
414414
---Handle permission.updated / permission.asked
415415
---@param permission OpencodePermission
416416
function M.on_permission_updated(permission)
417+
if not permission or not permission.id then
418+
return
419+
end
420+
417421
local tool = permission.tool
418422
local callID = tool and tool.callID or permission.callID
419423
local messageID = tool and tool.messageID or permission.messageID
420424

421-
if not permission or not messageID or not callID then
422-
return
423-
end
424-
425425
if not state.pending_permissions then
426426
state.renderer.set_pending_permissions({})
427427
end

tests/unit/permission_integration_spec.lua

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ local state = require('opencode.state')
22
local permission_window = require('opencode.ui.permission_window')
33
local events = require('opencode.ui.renderer.events')
44
local ctx = require('opencode.ui.renderer.ctx')
5+
local output_window = require('opencode.ui.output_window')
6+
local flush = require('opencode.ui.renderer.flush')
7+
local helpers = require('tests.helpers')
58

69
describe('permission_integration', function()
710
local mock_update_permission_from_part
@@ -402,3 +405,104 @@ describe('permission_integration', function()
402405
end)
403406
end)
404407
end)
408+
409+
describe('permission and question display ordering', function()
410+
before_each(function()
411+
helpers.replay_setup()
412+
state.session.set_active({ id = 'session_123' })
413+
end)
414+
415+
after_each(function()
416+
if state.windows then
417+
require('opencode.ui.ui').close_windows(state.windows)
418+
end
419+
end)
420+
421+
it('keeps the permission display pinned below later messages', function()
422+
events.on_message_updated({
423+
info = {
424+
id = 'msg_user',
425+
sessionID = 'session_123',
426+
role = 'user',
427+
},
428+
})
429+
events.on_part_updated({
430+
part = {
431+
id = 'part_user',
432+
messageID = 'msg_user',
433+
sessionID = 'session_123',
434+
type = 'text',
435+
text = 'first',
436+
},
437+
})
438+
439+
events.on_permission_updated({
440+
id = 'perm_1',
441+
permission = 'bash',
442+
title = 'Run command',
443+
metadata = {},
444+
})
445+
446+
events.on_message_updated({
447+
info = {
448+
id = 'msg_assistant',
449+
sessionID = 'session_123',
450+
role = 'assistant',
451+
},
452+
})
453+
events.on_part_updated({
454+
part = {
455+
id = 'part_assistant',
456+
messageID = 'msg_assistant',
457+
sessionID = 'session_123',
458+
type = 'text',
459+
text = 'later message',
460+
},
461+
})
462+
463+
flush.flush()
464+
465+
local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace)
466+
local permission_line = nil
467+
local assistant_line = nil
468+
for i, line in ipairs(actual.lines) do
469+
if line:find('Permission Required', 1, true) then
470+
permission_line = i
471+
elseif line == 'later message' then
472+
assistant_line = i
473+
end
474+
end
475+
476+
assert.is_not_nil(permission_line)
477+
assert.is_not_nil(assistant_line)
478+
assert.is_true(permission_line > assistant_line)
479+
end)
480+
end)
481+
482+
describe('permission prompt rendering', function()
483+
before_each(function()
484+
state.renderer.set_messages({})
485+
state.renderer.set_pending_permissions({})
486+
state.session.set_active({ id = 'session_123' })
487+
488+
permission_window._permission_queue = {}
489+
permission_window._dialog = nil
490+
permission_window._processing = false
491+
492+
ctx.render_state:reset()
493+
ctx.prev_line_count = 0
494+
end)
495+
496+
it('tracks and renders permissions without message correlation metadata', function()
497+
events.on_permission_updated({
498+
id = 'perm_no_meta',
499+
permission = 'bash',
500+
title = 'Run command',
501+
metadata = {},
502+
})
503+
504+
assert.are.equal(1, #state.pending_permissions)
505+
assert.are.equal('perm_no_meta', state.pending_permissions[1].id)
506+
assert.are.equal(1, permission_window.get_permission_count())
507+
end)
508+
end)

0 commit comments

Comments
 (0)