Skip to content

Commit 356a7d0

Browse files
authored
Merge branch 'sudo-tee:main' into refactor/command-boundary-a-clean
2 parents db4124f + 700066e commit 356a7d0

62 files changed

Lines changed: 9038 additions & 2871 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ require('opencode').setup({
234234
},
235235
output = {
236236
filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output')
237+
compact_assistant_headers = false, -- Collapse consecutive assistant headers in the same mode to a right-aligned timestamp only
237238
tools = {
238239
show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true)
239240
show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true)

lua/opencode/config.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,15 @@ M.defaults = {
139139
},
140140
output = {
141141
filetype = 'opencode_output',
142+
compact_assistant_headers = false,
142143
rendering = {
143144
markdown_debounce_ms = 250,
144145
on_data_rendered = nil,
146+
markdown_on_idle = false,
147+
-- If set to a number, markdown rendering will be deferred while
148+
-- `state.user_message_count[session_id]` is greater than this value.
149+
-- If `nil`, the existing behavior is used (defer while > 0).
150+
markdown_on_idle_threshold = nil,
145151
event_throttle_ms = 40,
146152
event_collapsing = true,
147153
},
@@ -255,6 +261,8 @@ M.defaults = {
255261
enabled = false,
256262
capture_streamed_events = false,
257263
show_ids = true,
264+
highlight_changed_lines = false,
265+
highlight_changed_lines_timeout_ms = 120,
258266
quick_chat = {
259267
keep_session = false,
260268
set_active_session = false,

lua/opencode/core.lua

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ M.select_session = Promise.async(function(parent_id)
2323
return s.title ~= '' and s ~= nil and s.parentID == parent_id
2424
end, all_sessions)
2525

26+
if #filtered_sessions == 0 then
27+
vim.notify(parent_id and 'No child sessions found' or 'No sessions found', vim.log.levels.INFO)
28+
if state.ui.is_visible() then
29+
ui.focus_input()
30+
end
31+
return
32+
end
33+
2634
ui.select_session(filtered_sessions, function(selected_session)
2735
if not selected_session then
2836
if state.ui.is_visible() then
@@ -520,15 +528,30 @@ M.ensure_current_mode = Promise.async(function()
520528
return true
521529
end)
522530

523-
---Initialize current model if it's not already set.
524-
---@return string|nil The current model (or the default model, if configured)
531+
---Initialize current model from messages or config.
532+
---@return string|nil The current model
525533
M.initialize_current_model = Promise.async(function()
534+
if state.messages then
535+
for i = #state.messages, 1, -1 do
536+
local msg = state.messages[i]
537+
if msg and msg.info and msg.info.modelID and msg.info.providerID then
538+
local model_str = msg.info.providerID .. '/' .. msg.info.modelID
539+
if state.current_model ~= model_str then
540+
state.model.set_model(model_str)
541+
end
542+
if msg.info.mode and state.current_mode ~= msg.info.mode then
543+
state.model.set_mode(msg.info.mode)
544+
end
545+
return state.current_model
546+
end
547+
end
548+
end
549+
526550
if state.current_model then
527551
return state.current_model
528552
end
529553

530554
local cfg = require('opencode.config_file').get_opencode_config():await()
531-
532555
if cfg and cfg.model and cfg.model ~= '' then
533556
state.model.set_model(cfg.model)
534557
end
@@ -537,6 +560,8 @@ M.initialize_current_model = Promise.async(function()
537560
end)
538561

539562
M._on_user_message_count_change = Promise.async(function(_, new, old)
563+
require('opencode.ui.renderer.flush').flush_pending_on_data_rendered()
564+
540565
if config.hooks and config.hooks.on_done_thinking then
541566
local all_sessions = session.get_all_workspace_sessions():await()
542567
local done_sessions = vim.tbl_filter(function(s)

lua/opencode/event_manager.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ local log = require('opencode.log')
183183
--- @field state_cwd_listener function|nil Listener for state.current_cwd updates
184184
--- @field is_started boolean Whether the event manager is started
185185
--- @field captured_events table[] List of captured events for debugging
186+
--- @field ignored_events string[] List of event types to ignore when capturing
186187
--- @field throttling_emitter ThrottlingEmitter Throttle instance for batching events
187188
local EventManager = {}
188189
EventManager.__index = EventManager
@@ -197,6 +198,7 @@ function EventManager.new()
197198
state_cwd_listener = nil,
198199
is_started = false,
199200
captured_events = {},
201+
ignored_events = { 'server.heartbeat' },
200202
_parts_by_id = {},
201203
}, EventManager)
202204

@@ -553,6 +555,14 @@ function EventManager:_subscribe_to_server_events(server)
553555
local api_client = state.api_client
554556

555557
local emitter = function(event)
558+
if not event or not event.type then
559+
log.warn('Received malformed event from server: %s', vim.inspect(event))
560+
return
561+
end
562+
if self.ignored_events and vim.tbl_contains(self.ignored_events, event.type) then
563+
log.debug('Ignoring event of type %s', event.type)
564+
return
565+
end
556566
self.throttling_emitter:enqueue(event)
557567
end
558568

lua/opencode/id.lua

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,3 @@ function M.get_prefixes()
142142
end
143143

144144
return M
145-

lua/opencode/throttling_emitter.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ function ThrottlingEmitter:_drain()
4646
local items_to_process = self.queue
4747
self.queue = {}
4848

49-
self.process_fn(items_to_process)
49+
if #items_to_process > 0 then
50+
self.process_fn(items_to_process)
51+
end
5052

5153
-- double check that items weren't added while processing
5254
if #self.queue > 0 and not self.drain_scheduled then

lua/opencode/types.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@
231231
---@class OpencodeUIOutputRenderingConfig
232232
---@field markdown_debounce_ms number
233233
---@field on_data_rendered (fun(buf: integer, win: integer)|boolean)|nil
234+
---@field markdown_on_idle boolean
234235
---@field event_throttle_ms number
235236
---@field event_collapsing boolean
236237

@@ -239,6 +240,7 @@
239240
---@field rendering OpencodeUIOutputRenderingConfig
240241
---@field always_scroll_to_bottom boolean
241242
---@field filetype string
243+
---@field compact_assistant_headers boolean
242244

243245
---@class OpencodeUIPickerConfig
244246
---@field snacks_layout? snacks.picker.layout.Config
@@ -266,6 +268,8 @@
266268
---@field enabled boolean
267269
---@field capture_streamed_events boolean
268270
---@field show_ids boolean
271+
---@field highlight_changed_lines boolean
272+
---@field highlight_changed_lines_timeout_ms number
269273
---@field quick_chat {keep_session: boolean, set_active_session: boolean}
270274

271275
---@alias OpencodeCommandLifecycleStage 'before'|'after'|'error'|'finally'

lua/opencode/ui/base_picker.lua

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ local Promise = require('opencode.promise')
1919
---@field multi_selection? table<string, boolean> Actions that support multi-selection
2020
---@field preview? "file"|"none"|false Preview mode: "file" for file preview, "none" or false to disable
2121
---@field layout_opts? OpencodeUIPickerConfig
22+
---@field close? fun() Close the picker programmatically (set by the backend)
2223

2324
---@class TelescopeEntry
2425
---@field value any
@@ -84,6 +85,8 @@ local function telescope_ui(opts)
8485
local entry_display = require('telescope.pickers.entry_display')
8586

8687
-- Create displayer dynamically based on number of parts
88+
---@param picker_item PickerItem
89+
---@return table
8790
local function create_displayer(picker_item)
8891
local items = {}
8992
for _ in ipairs(picker_item.parts) do
@@ -128,6 +131,7 @@ local function telescope_ui(opts)
128131
return entry
129132
end
130133

134+
---@return unknown
131135
local function refresh_picker()
132136
return current_picker
133137
and current_picker:refresh(
@@ -147,6 +151,11 @@ local function telescope_ui(opts)
147151
width = opts.width + 7, -- extra space for telescope UI
148152
} or nil,
149153
attach_mappings = function(prompt_bufnr, map)
154+
opts.close = function()
155+
selection_made = true
156+
actions.close(prompt_bufnr)
157+
end
158+
150159
actions.select_default:replace(function()
151160
selection_made = true
152161
local selection = action_state.get_selected_entry()
@@ -197,8 +206,12 @@ local function telescope_ui(opts)
197206
local new_items = action.fn(items_to_process, opts)
198207
Promise.wrap(new_items):and_then(function(resolved_items)
199208
if action.reload and resolved_items then
200-
opts.items = resolved_items
201-
refresh_picker()
209+
if #resolved_items == 0 and opts.close then
210+
opts.close()
211+
else
212+
opts.items = resolved_items
213+
refresh_picker()
214+
end
202215
end
203216
end)
204217
end
@@ -222,6 +235,7 @@ end
222235
local function fzf_ui(opts)
223236
local fzf_lua = require('fzf-lua')
224237

238+
---@return table
225239
local function create_fzf_config()
226240
local has_multi_action = util.some(opts.actions, function(action)
227241
return action.multi_selection
@@ -251,6 +265,7 @@ local function fzf_ui(opts)
251265
}
252266
end
253267

268+
---@return fun(fzf_cb: fun(line?: string))
254269
local function create_finder()
255270
return function(fzf_cb)
256271
for idx, item in ipairs(opts.items) do
@@ -289,15 +304,40 @@ local function fzf_ui(opts)
289304
end
290305
end
291306

307+
---Reopen fzf-lua to reflect updated picker items.
292308
local function refresh_fzf()
293309
vim.schedule(function()
294310
fzf_ui(opts)
295311
end)
296312
end
297313

314+
local closed = false
315+
316+
opts.close = function()
317+
if closed then
318+
return
319+
end
320+
closed = true
321+
vim.schedule(function()
322+
local ok, fzf_win = pcall(require, 'fzf-lua.win')
323+
if ok and fzf_win.__SELF then
324+
local win = fzf_win.__SELF()
325+
if win then
326+
win:close()
327+
end
328+
end
329+
if opts.callback then
330+
opts.callback(nil)
331+
end
332+
end)
333+
end
334+
298335
---@type FzfLuaActions
299336
local actions_config = {
300337
['default'] = function(selected, fzf_opts)
338+
if closed then
339+
return
340+
end
301341
if not selected or #selected == 0 then
302342
if opts.callback then
303343
opts.callback(nil)
@@ -310,6 +350,9 @@ local function fzf_ui(opts)
310350
end
311351
end,
312352
['esc'] = function()
353+
if closed then
354+
return
355+
end
313356
if opts.callback then
314357
opts.callback(nil)
315358
end
@@ -346,8 +389,12 @@ local function fzf_ui(opts)
346389
Promise.wrap(new_items):and_then(function(resolved_items)
347390
if action.reload and resolved_items then
348391
---@cast resolved_items any[]
349-
opts.items = resolved_items
350-
refresh_fzf()
392+
if #resolved_items == 0 and opts.close then
393+
opts.close()
394+
else
395+
opts.items = resolved_items
396+
refresh_fzf()
397+
end
351398
end
352399
end)
353400
end
@@ -376,6 +423,10 @@ local function mini_pick_ui(opts)
376423

377424
local mappings = {}
378425

426+
opts.close = function()
427+
mini_pick.stop()
428+
end
429+
379430
for action_name, action in pairs(opts.actions) do
380431
if action.key and action.key[1] then
381432
mappings[action_name] = {
@@ -387,8 +438,12 @@ local function mini_pick_ui(opts)
387438
local new_items = action.fn(current.item, opts)
388439
Promise.wrap(new_items):and_then(function(resolved_items)
389440
if action.reload and resolved_items then
390-
opts.items = resolved_items
391-
mini_pick_ui(opts)
441+
if #resolved_items == 0 and opts.close then
442+
opts.close()
443+
else
444+
opts.items = resolved_items
445+
mini_pick_ui(opts)
446+
end
392447
end
393448
end)
394449
end
@@ -522,6 +577,13 @@ local function snacks_picker_ui(opts)
522577
snack_opts.win.input.keys[action.key[1]] = { action_name, mode = action.key.mode or 'i' }
523578

524579
snack_opts.actions[action_name] = function(_picker, item)
580+
if not opts.close then
581+
opts.close = function()
582+
selection_made = true
583+
_picker:close()
584+
end
585+
end
586+
525587
if item then
526588
local items_to_process
527589
if action.multi_selection then
@@ -540,9 +602,13 @@ local function snacks_picker_ui(opts)
540602
local new_items = action.fn(items_to_process, opts)
541603
Promise.wrap(new_items):and_then(function(resolved_items)
542604
if action.reload and resolved_items then
543-
opts.items = resolved_items
544-
_picker:refresh()
545-
_picker:find()
605+
if #resolved_items == 0 and opts.close then
606+
opts.close()
607+
else
608+
opts.items = resolved_items
609+
_picker:refresh()
610+
_picker:find()
611+
end
546612
end
547613
end)
548614
end)
@@ -584,6 +650,7 @@ function M.create_picker_item(parts)
584650
parts = parts,
585651
}
586652

653+
---@return string
587654
function item:to_string()
588655
local texts = {}
589656
for _, part in ipairs(self.parts) do
@@ -592,6 +659,7 @@ function M.create_picker_item(parts)
592659
return table.concat(texts, ' ')
593660
end
594661

662+
---@return table
595663
function item:to_formatted_text()
596664
local formatted = {}
597665
for _, part in ipairs(self.parts) do

lua/opencode/ui/completion/files.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ local function create_file_item(file, suffix, priority)
8585
local dir = vim.fn.fnamemodify(file, ':h')
8686
local file_path = dir == '.' and filename or dir .. '/' .. filename
8787
local detail = dir == '.' and filename or dir .. '/' .. filename
88-
local full_path = vim.fn.fnamemodify(file, ':p')
88+
-- Build absolute path without resolving symlinks so that files inside
89+
-- symlinked directories within cwd pass the is_path_in_cwd check.
90+
local full_path = file:sub(1, 1) == '/' and file or (vim.fn.getcwd() .. '/' .. file)
8991
local display_label = file_path
9092

9193
local file_config = config.ui.completion.file_sources

0 commit comments

Comments
 (0)