From b4bdd01ee006bb9c1917acbb2f131402038d912c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 25 May 2026 09:16:32 -0400 Subject: [PATCH] feat(output): preview latest N lines below closed folds Add a new ui.output.tools setting only_show_latest_n that keeps the latest N lines visible below a closed fold for long-running tool output. Update folding logic to compute fold endpoints respecting this preview count, update fold text to mention the preview when enabled, lower the default folding threshold. --- README.md | 5 +- lua/opencode/config.lua | 2 + lua/opencode/types.lua | 2 +- lua/opencode/ui/output.lua | 18 +++++- lua/opencode/ui/output_window.lua | 14 ++++- tests/unit/output_spec.lua | 94 +++++++++++++++++++++++++++++++ tests/unit/output_window_spec.lua | 46 +++++++++++++++ 7 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 tests/unit/output_spec.lua diff --git a/README.md b/README.md index b8d417fa..8ae4e9a2 100644 --- a/README.md +++ b/README.md @@ -236,12 +236,13 @@ require('opencode').setup({ }, output = { filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') - compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant) + compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant) tools = { show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true) show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true) use_folds = true, -- Use folds for tool output (default: true) - folding_threshold = 5, -- Number of lines to show before folding when show_output is true (default: 5) + only_show_latest_n = nil, -- Keep the latest N lines visible below a closed fold; nil keeps the current behavior (default: nil) + folding_threshold = 25, -- Number of leading lines to keep visible before folding when show_output is true (default: 25) }, rendering = { markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 923c84b4..5bd41e6b 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -177,6 +177,8 @@ M.defaults = { show_output = true, show_reasoning_output = true, use_folds = true, + -- Keep the latest N lines visible below folds for long-running progress. + only_show_latest_n = 3, -- Reduced default threshold to make small tool outputs foldable by default. -- Users can override this in their config if they prefer the previous value. folding_threshold = 25, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4b83a9d4..deb101d1 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -254,7 +254,7 @@ ---@class OpencodeUIOutputConfig ---@field time_format string|nil # Custom os.date format for timestamps, e.g. '%m/%d %H:%M'. Uses fixed default when nil. ----@field tools { show_output: boolean, show_reasoning_output: boolean, use_folds: boolean, folding_threshold: number } +---@field tools { show_output: boolean, show_reasoning_output: boolean, use_folds: boolean, folding_threshold: number, only_show_latest_n: integer|nil } ---@field rendering OpencodeUIOutputRenderingConfig ---@field max_messages integer|nil ---@field always_scroll_to_bottom boolean diff --git a/lua/opencode/ui/output.lua b/lua/opencode/ui/output.lua index dbfc6c3b..1ea5751b 100644 --- a/lua/opencode/ui/output.lua +++ b/lua/opencode/ui/output.lua @@ -103,14 +103,26 @@ function Output:add_fold_with_threshold(start_line, show, use_folds) end local threshold = config.ui.output.tools.folding_threshold or 5 + local only_show_latest_n = config.ui.output.tools.only_show_latest_n + if type(only_show_latest_n) ~= 'number' or only_show_latest_n < 1 then + only_show_latest_n = 0 + else + only_show_latest_n = math.floor(only_show_latest_n) + end + local end_line = self:get_line_count() if not show then - if end_line > start_line then - self:add_fold(start_line, end_line) + local fold_end = end_line - only_show_latest_n + if fold_end >= start_line then + self:add_fold(start_line, fold_end) end elseif end_line > start_line + threshold then - self:add_fold(start_line + threshold, end_line) + local fold_start = start_line + threshold + local fold_end = end_line - only_show_latest_n + if fold_end >= fold_start then + self:add_fold(fold_start, fold_end) + end end end diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5bca0207..b7fc9a07 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -339,6 +339,13 @@ function M.fold_text() local windows = state.windows local output_buf = windows and windows.output_buf local line = vim.v.foldstart + local latest_n = config.ui.output.tools.only_show_latest_n + + if type(latest_n) ~= 'number' or latest_n < 1 then + latest_n = nil + else + latest_n = math.floor(latest_n) + end if not output_buf or not vim.api.nvim_buf_is_valid(output_buf) then return vim.fn.foldtext() @@ -355,7 +362,12 @@ function M.fold_text() end if line_count > 0 then - local text = string.format('▶ %d lines hidden (zo open, zc close) ◀', line_count) + local text + if latest_n then + text = string.format('▶ %d lines hidden, latest %d below (zo open, zc close) ◀', line_count, latest_n) + else + text = string.format('▶ %d lines hidden (zo open, zc close) ◀', line_count) + end local width = vim.api.nvim_win_get_width(0) local padding = math.max(0, math.floor((width - #text) / 2)) return string.rep('-', padding) .. text .. string.rep('-', padding) diff --git a/tests/unit/output_spec.lua b/tests/unit/output_spec.lua new file mode 100644 index 00000000..03a71de0 --- /dev/null +++ b/tests/unit/output_spec.lua @@ -0,0 +1,94 @@ +local config = require('opencode.config') +local Output = require('opencode.ui.output') + +describe('output fold thresholds', function() + local original_config + + before_each(function() + original_config = vim.deepcopy(config.values) + config.values = vim.deepcopy(config.defaults) + end) + + after_each(function() + config.values = original_config + end) + +it('uses the default latest-line preview when only_show_latest_n is not overridden', function() + config.setup({ + ui = { + output = { + tools = { + folding_threshold = 3, + }, + }, + }, + }) + + local output = Output.new() + output:add_lines({ '1', '2', '3', '4', '5', '6', '7' }) + + output:add_fold_with_threshold(1, true, true) + + assert.same({ { from = 4, to = 4 } }, output.fold_ranges) +end) + +it('preserves the current fold behavior when only_show_latest_n is explicitly disabled', function() + config.setup({ + ui = { + output = { + tools = { + folding_threshold = 3, + only_show_latest_n = 0, + }, + }, + }, + }) + + local output = Output.new() + output:add_lines({ '1', '2', '3', '4', '5', '6', '7' }) + + output:add_fold_with_threshold(1, true, true) + + assert.same({ { from = 4, to = 7 } }, output.fold_ranges) +end) + + it('keeps the latest lines visible below folds when output is shown', function() + config.setup({ + ui = { + output = { + tools = { + folding_threshold = 3, + only_show_latest_n = 2, + }, + }, + }, + }) + + local output = Output.new() + output:add_lines({ '1', '2', '3', '4', '5', '6', '7' }) + + output:add_fold_with_threshold(1, true, true) + + assert.same({ { from = 4, to = 5 } }, output.fold_ranges) + end) + + it('keeps the latest lines visible when output is otherwise hidden', function() + config.setup({ + ui = { + output = { + tools = { + show_output = false, + only_show_latest_n = 2, + }, + }, + }, + }) + + local output = Output.new() + output:add_lines({ '1', '2', '3', '4', '5', '6', '7' }) + + output:add_fold_with_threshold(1, false, true) + + assert.same({ { from = 1, to = 5 } }, output.fold_ranges) + end) +end) diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 8d0f7c9a..9def6ae8 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -211,6 +211,52 @@ describe('output_window.setup', function() end) end) +describe('output_window.fold_text', function() + local buf + local win + + before_each(function() + buf = vim.api.nvim_create_buf(false, true) + win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = 100, + height = 10, + row = 0, + col = 0, + }) + state.ui.set_windows({ output_buf = buf, output_win = win }) + output_window.setup({ output_buf = buf, output_win = win }) + output_window.set_lines({ 'a', 'b', 'c', 'd', 'e', 'f' }) + end) + + after_each(function() + state.ui.set_windows(nil) + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('mentions visible latest lines when latest preview is enabled', function() + config.setup({ + ui = { + output = { + tools = { + only_show_latest_n = 2, + }, + }, + }, + }) + + output_window.set_folds({ { from = 2, to = 4 } }) + + local text = vim.api.nvim_win_call(win, function() + vim.v.foldstart = 2 + return output_window.fold_text() + end) + + assert.is_truthy(text:find('3 lines hidden, latest 2 below', 1, true)) + end) +end) + describe('output_window extmarks', function() local buf