Skip to content

Commit e944908

Browse files
committed
fix(ui): use cursor position for scroll-intent tracking
- output_window: is_at_bottom() checks cursor instead of viewport (viewport almost always at bottom in streaming chat) - CursorMoved autocmd replaces WinScrolled for both windows - state: add cursor persistence functions (save/get/set) - output_window: add buffer_valid() guard for buffer-only operations
1 parent b379c29 commit e944908

4 files changed

Lines changed: 280 additions & 17 deletions

File tree

lua/opencode/state.lua

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,68 @@ function M.is_running()
196196
return M.job_count > 0
197197
end
198198

199+
---@param pos any
200+
---@return integer[]|nil
201+
local function normalize_cursor(pos)
202+
if type(pos) ~= 'table' or #pos < 2 then
203+
return nil
204+
end
205+
local line = tonumber(pos[1])
206+
local col = tonumber(pos[2])
207+
if not line or not col then
208+
return nil
209+
end
210+
return { math.max(1, math.floor(line)), math.max(0, math.floor(col)) }
211+
end
212+
213+
---Save cursor position from a window
214+
---@param win_type 'input'|'output'
215+
---@param win_id integer|nil
216+
---@return integer[]|nil
217+
function M.save_cursor_position(win_type, win_id)
218+
if not win_id or not vim.api.nvim_win_is_valid(win_id) then
219+
return nil
220+
end
221+
222+
local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id)
223+
if not ok then
224+
return nil
225+
end
226+
227+
local normalized = normalize_cursor(pos)
228+
if not normalized then
229+
return nil
230+
end
231+
232+
M.set_cursor_position(win_type, normalized)
233+
return normalized
234+
end
235+
236+
---Set saved cursor position
237+
---@param win_type 'input'|'output'
238+
---@param pos integer[]|nil
239+
function M.set_cursor_position(win_type, pos)
240+
local normalized = normalize_cursor(pos)
241+
if win_type == 'input' then
242+
_state.last_input_window_position = normalized
243+
elseif win_type == 'output' then
244+
_state.last_output_window_position = normalized
245+
end
246+
end
247+
248+
---Get saved cursor position
249+
---@param win_type 'input'|'output'
250+
---@return integer[]|nil
251+
function M.get_cursor_position(win_type)
252+
if win_type == 'input' then
253+
return normalize_cursor(_state.last_input_window_position)
254+
end
255+
if win_type == 'output' then
256+
return normalize_cursor(_state.last_output_window_position)
257+
end
258+
return nil
259+
end
260+
199261
--- Observable state proxy. All reads/writes go through this table.
200262
--- Use `state.subscribe(key, cb)` to listen for changes.
201263
--- Use `state.unsubscribe(key, cb)` to remove listeners.

lua/opencode/ui/input_window.lua

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,14 @@ function M.setup_autocmds(windows, group)
505505
M.schedule_resize(windows)
506506
end,
507507
})
508+
509+
vim.api.nvim_create_autocmd('CursorMoved', {
510+
group = group,
511+
buffer = windows.input_buf,
512+
callback = function()
513+
state.save_cursor_position('input', windows.input_win)
514+
end,
515+
})
508516
end
509517

510518
---Toggle the input window visibility (hide/show)
@@ -531,7 +539,7 @@ function M._hide()
531539
end
532540

533541
local output_window = require('opencode.ui.output_window')
534-
local was_at_bottom = output_window.viewport_at_bottom
542+
local was_at_bottom = output_window.is_at_bottom(windows.output_win)
535543

536544
M._hidden = true
537545
M._toggling = true
@@ -566,7 +574,7 @@ function M._show()
566574
end
567575

568576
local output_window = require('opencode.ui.output_window')
569-
local was_at_bottom = output_window.viewport_at_bottom
577+
local was_at_bottom = output_window.is_at_bottom(windows.output_win)
570578

571579
local output_win = windows.output_win
572580
if not vim.api.nvim_win_is_valid(output_win) then

lua/opencode/ui/output_window.lua

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ local config = require('opencode.config')
33

44
local M = {}
55
M.namespace = vim.api.nvim_create_namespace('opencode_output')
6-
M.viewport_at_bottom = true
76

87
function M.create_buf()
98
local output_buf = vim.api.nvim_create_buf(false, true)
@@ -35,9 +34,17 @@ function M.mounted(windows)
3534
return windows and windows.output_buf and windows.output_win and vim.api.nvim_win_is_valid(windows.output_win)
3635
end
3736

38-
---Check if the output window is currently at the bottom
37+
---Check if the output buffer is valid (even if window is hidden)
38+
---@param windows? OpencodeWindowState
39+
---@return boolean
40+
function M.buffer_valid(windows)
41+
windows = windows or state.windows
42+
return windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf)
43+
end
44+
45+
---Check if the cursor in output window is at the bottom
3946
---@param win? integer Window ID, defaults to state.windows.output_win
40-
---@return boolean true if at bottom, false otherwise
47+
---@return boolean true if cursor at bottom, false otherwise
4148
function M.is_at_bottom(win)
4249
if config.ui.output.always_scroll_to_bottom then
4350
return true
@@ -58,11 +65,12 @@ function M.is_at_bottom(win)
5865
return true
5966
end
6067

61-
local botline = vim.fn.line('w$', win)
68+
local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win)
69+
if not ok2 then
70+
return true
71+
end
6272

63-
-- Consider at bottom if bottom visible line is at or near the end
64-
-- Use -1 tolerance for wrapped lines
65-
return botline >= line_count - 1
73+
return cursor[1] >= line_count
6674
end
6775

6876
---Helper to set window option and save original value for position='current'
@@ -132,7 +140,7 @@ function M.update_dimensions(windows)
132140
end
133141

134142
function M.get_buf_line_count()
135-
if not M.mounted() then
143+
if not M.buffer_valid() then
136144
return 0
137145
end
138146
---@cast state.windows { output_buf: integer }
@@ -145,7 +153,7 @@ end
145153
---@param start_line? integer The starting line to set, defaults to 0
146154
---@param end_line? integer The last line to set, defaults to -1
147155
function M.set_lines(lines, start_line, end_line)
148-
if not M.mounted() then
156+
if not M.buffer_valid() then
149157
return
150158
end
151159
---@cast state.windows { output_buf: integer }
@@ -163,7 +171,7 @@ end
163171
---@param end_line? integer Line to clear until, defaults to -1
164172
---@param clear_all? boolean If true, clears all extmarks in the buffer
165173
function M.clear_extmarks(start_line, end_line, clear_all)
166-
if not M.mounted() then
174+
if not M.buffer_valid() then
167175
return
168176
end
169177
---@cast state.windows { output_buf: integer }
@@ -184,7 +192,7 @@ end
184192
---@param extmarks table<number, OutputExtmark[]> Extmarks indexed by line
185193
---@param line_offset? integer Line offset to apply to extmarks, defaults to 0
186194
function M.set_extmarks(extmarks, line_offset)
187-
if not M.mounted() or not extmarks or type(extmarks) ~= 'table' then
195+
if not M.buffer_valid() or not extmarks or type(extmarks) ~= 'table' then
188196
return
189197
end
190198
---@cast state.windows { output_buf: integer }
@@ -262,12 +270,11 @@ function M.setup_autocmds(windows, group)
262270
end,
263271
})
264272

265-
-- Track scroll position when window is scrolled
266-
vim.api.nvim_create_autocmd('WinScrolled', {
273+
vim.api.nvim_create_autocmd('CursorMoved', {
267274
group = group,
268275
buffer = windows.output_buf,
269276
callback = function()
270-
M.viewport_at_bottom = M.is_at_bottom(windows.output_win)
277+
state.save_cursor_position('output', windows.output_win)
271278
end,
272279
})
273280
end
@@ -277,7 +284,6 @@ function M.clear()
277284
-- clear extmarks in all namespaces as I've seen RenderMarkdown leave some
278285
-- extmarks behind
279286
M.clear_extmarks(0, -1, true)
280-
M.viewport_at_bottom = true
281287
end
282288

283289
---Get the output buffer
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
local state = require('opencode.state')
2+
local config = require('opencode.config')
3+
4+
describe('cursor persistence (state)', function()
5+
before_each(function()
6+
state.set_cursor_position('input', nil)
7+
state.set_cursor_position('output', nil)
8+
end)
9+
10+
describe('set/get round-trip', function()
11+
it('stores and retrieves input cursor', function()
12+
state.set_cursor_position('input', { 5, 3 })
13+
assert.same({ 5, 3 }, state.get_cursor_position('input'))
14+
end)
15+
16+
it('stores and retrieves output cursor', function()
17+
state.set_cursor_position('output', { 10, 0 })
18+
assert.same({ 10, 0 }, state.get_cursor_position('output'))
19+
end)
20+
21+
it('input and output are independent', function()
22+
state.set_cursor_position('input', { 1, 0 })
23+
state.set_cursor_position('output', { 99, 5 })
24+
assert.same({ 1, 0 }, state.get_cursor_position('input'))
25+
assert.same({ 99, 5 }, state.get_cursor_position('output'))
26+
end)
27+
28+
it('returns nil for unknown win_type', function()
29+
assert.is_nil(state.get_cursor_position('footer'))
30+
end)
31+
end)
32+
33+
describe('normalize_cursor edge cases', function()
34+
it('clamps negative line to 1', function()
35+
state.set_cursor_position('input', { -5, 3 })
36+
local pos = state.get_cursor_position('input')
37+
assert.equals(1, pos[1])
38+
end)
39+
40+
it('clamps negative col to 0', function()
41+
state.set_cursor_position('input', { 1, -1 })
42+
local pos = state.get_cursor_position('input')
43+
assert.equals(0, pos[2])
44+
end)
45+
46+
it('floors fractional values', function()
47+
state.set_cursor_position('input', { 3.7, 2.9 })
48+
local pos = state.get_cursor_position('input')
49+
assert.equals(3, pos[1])
50+
assert.equals(2, pos[2])
51+
end)
52+
53+
it('rejects non-table input', function()
54+
state.set_cursor_position('input', 'bad')
55+
assert.is_nil(state.get_cursor_position('input'))
56+
end)
57+
58+
it('rejects table with fewer than 2 elements', function()
59+
state.set_cursor_position('input', { 1 })
60+
assert.is_nil(state.get_cursor_position('input'))
61+
end)
62+
63+
it('rejects non-numeric elements', function()
64+
state.set_cursor_position('input', { 'a', 'b' })
65+
assert.is_nil(state.get_cursor_position('input'))
66+
end)
67+
68+
it('clears position when set to nil', function()
69+
state.set_cursor_position('input', { 5, 3 })
70+
state.set_cursor_position('input', nil)
71+
assert.is_nil(state.get_cursor_position('input'))
72+
end)
73+
end)
74+
75+
describe('save_cursor_position', function()
76+
it('returns nil for invalid window', function()
77+
assert.is_nil(state.save_cursor_position('input', nil))
78+
assert.is_nil(state.save_cursor_position('input', 999999))
79+
end)
80+
81+
it('captures and persists from a real window', function()
82+
local buf = vim.api.nvim_create_buf(false, true)
83+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line1', 'line2', 'line3' })
84+
local win = vim.api.nvim_open_win(buf, true, {
85+
relative = 'editor', width = 40, height = 10, row = 0, col = 0,
86+
})
87+
vim.api.nvim_win_set_cursor(win, { 2, 3 })
88+
89+
local saved = state.save_cursor_position('output', win)
90+
assert.same({ 2, 3 }, saved)
91+
assert.same({ 2, 3 }, state.get_cursor_position('output'))
92+
93+
pcall(vim.api.nvim_win_close, win, true)
94+
pcall(vim.api.nvim_buf_delete, buf, { force = true })
95+
end)
96+
end)
97+
end)
98+
99+
describe('output_window.is_at_bottom', function()
100+
local output_window = require('opencode.ui.output_window')
101+
local buf, win
102+
103+
before_each(function()
104+
config.setup({})
105+
buf = vim.api.nvim_create_buf(false, true)
106+
local lines = {}
107+
for i = 1, 50 do
108+
lines[i] = 'line ' .. i
109+
end
110+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
111+
112+
win = vim.api.nvim_open_win(buf, true, {
113+
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
114+
})
115+
116+
state.windows = { output_win = win, output_buf = buf }
117+
end)
118+
119+
after_each(function()
120+
pcall(vim.api.nvim_win_close, win, true)
121+
pcall(vim.api.nvim_buf_delete, buf, { force = true })
122+
state.windows = nil
123+
end)
124+
125+
it('returns true when cursor is on last line', function()
126+
vim.api.nvim_win_set_cursor(win, { 50, 0 })
127+
assert.is_true(output_window.is_at_bottom(win))
128+
end)
129+
130+
it('returns false when cursor is on second-to-last line', function()
131+
vim.api.nvim_win_set_cursor(win, { 49, 0 })
132+
assert.is_false(output_window.is_at_bottom(win))
133+
end)
134+
135+
it('returns false when cursor is far from bottom', function()
136+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
137+
assert.is_false(output_window.is_at_bottom(win))
138+
end)
139+
140+
it('returns false when cursor is a few lines above bottom', function()
141+
vim.api.nvim_win_set_cursor(win, { 45, 0 })
142+
assert.is_false(output_window.is_at_bottom(win))
143+
end)
144+
145+
it('returns true when always_scroll_to_bottom is enabled', function()
146+
config.values.ui.output.always_scroll_to_bottom = true
147+
vim.api.nvim_win_set_cursor(win, { 1, 0 })
148+
assert.is_true(output_window.is_at_bottom(win))
149+
config.values.ui.output.always_scroll_to_bottom = false
150+
end)
151+
152+
it('returns true for invalid window', function()
153+
assert.is_true(output_window.is_at_bottom(999999))
154+
end)
155+
156+
it('returns true when no windows in state', function()
157+
state.windows = nil
158+
assert.is_true(output_window.is_at_bottom(win))
159+
end)
160+
161+
it('returns true for empty buffer', function()
162+
local empty_buf = vim.api.nvim_create_buf(false, true)
163+
local empty_win = vim.api.nvim_open_win(empty_buf, true, {
164+
relative = 'editor', width = 40, height = 5, row = 0, col = 0,
165+
})
166+
state.windows = { output_win = empty_win, output_buf = empty_buf }
167+
168+
assert.is_true(output_window.is_at_bottom(empty_win))
169+
170+
pcall(vim.api.nvim_win_close, empty_win, true)
171+
pcall(vim.api.nvim_buf_delete, empty_buf, { force = true })
172+
end)
173+
174+
it('cursor-based: scrolling viewport without moving cursor does NOT change result', function()
175+
vim.api.nvim_win_set_cursor(win, { 50, 0 })
176+
assert.is_true(output_window.is_at_bottom(win))
177+
178+
-- Scroll viewport up via winrestview, cursor stays at line 50
179+
pcall(vim.api.nvim_win_call, win, function()
180+
vim.fn.winrestview({ topline = 1 })
181+
end)
182+
183+
-- Cursor is still at 50, so is_at_bottom should still be true
184+
-- This is the key behavioral difference from viewport-based check
185+
assert.is_true(output_window.is_at_bottom(win))
186+
end)
187+
end)

0 commit comments

Comments
 (0)