Skip to content

Commit aea457a

Browse files
sudo-teecameronr
andauthored
feat(timeline-picker): implement basic timeline picker (#101)
* refactor(picker_utils): create a base picker This creates a base picker module to make it as easy as possible to create new ones with the same pattern It also aligns the date to the right properly for every picker --------- Co-authored-by: Cameron Ring <[email protected]>
1 parent bdbd9ef commit aea457a

23 files changed

+747
-545
lines changed

README.md

Lines changed: 55 additions & 49 deletions
Large diffs are not rendered by default.

lua/opencode/api.lua

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,63 @@ function M.undo(messageId)
680680
end)
681681
end
682682

683+
function M.timeline()
684+
local user_messages = {}
685+
for _, msg in ipairs(state.messages or {}) do
686+
local parts = msg.parts or {}
687+
local is_summary = #parts == 1 and parts[1].synthetic == true
688+
if msg.info.role == 'user' and not is_summary then
689+
table.insert(user_messages, msg)
690+
end
691+
end
692+
if #user_messages == 0 then
693+
vim.notify('No user messages in the current session', vim.log.levels.WARN)
694+
return
695+
end
696+
697+
local timeline_picker = require('opencode.ui.timeline_picker')
698+
timeline_picker.pick(user_messages, function(selected_msg)
699+
if selected_msg then
700+
require('opencode.ui.navigation').goto_message_by_id(selected_msg.info.id)
701+
end
702+
end)
703+
end
704+
705+
--- Forks the current session from a specific user message.
706+
---@param message_id? string The ID of the user message to fork from. If not provided, uses the last user message.
707+
function M.fork_session(message_id)
708+
if not state.active_session then
709+
vim.notify('No active session to fork', vim.log.levels.WARN)
710+
return
711+
end
712+
713+
local message_to_fork = message_id or state.last_user_message and state.last_user_message.info.id
714+
if not message_to_fork then
715+
vim.notify('No user message to fork from', vim.log.levels.WARN)
716+
return
717+
end
718+
719+
state.api_client
720+
:fork_session(state.active_session.id, {
721+
messageID = message_to_fork,
722+
})
723+
:and_then(function(response)
724+
vim.schedule(function()
725+
if response and response.id then
726+
vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO)
727+
core.switch_session(response.id)
728+
else
729+
vim.notify('Session forked but no new session ID received', vim.log.levels.WARN)
730+
end
731+
end)
732+
end)
733+
:catch(function(err)
734+
vim.schedule(function()
735+
vim.notify('Failed to fork session: ' .. vim.inspect(err), vim.log.levels.ERROR)
736+
end)
737+
end)
738+
end
739+
683740
-- Returns the ID of the next user message after the current undo point
684741
-- This is a port of the opencode tui logic
685742
-- https://github.com/sst/opencode/blob/dev/packages/tui/internal/components/chat/messages.go#L1199
@@ -1074,6 +1131,11 @@ M.commands = {
10741131
end
10751132
end,
10761133
},
1134+
1135+
timeline = {
1136+
desc = 'Open timeline picker to navigate/undo/redo/fork to message',
1137+
fn = M.timeline,
1138+
},
10771139
}
10781140

10791141
M.slash_commands_map = {
@@ -1089,6 +1151,7 @@ M.slash_commands_map = {
10891151
['/redo'] = { fn = M.redo, desc = 'Redo last action' },
10901152
['/sessions'] = { fn = M.select_session, desc = 'Select session' },
10911153
['/share'] = { fn = M.share, desc = 'Share current session' },
1154+
['/timeline'] = { fn = M.timeline, desc = 'Open timeline picker' },
10921155
['/undo'] = { fn = M.undo, desc = 'Undo last action' },
10931156
['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' },
10941157
}

lua/opencode/api_client.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ function OpencodeApiClient:summarize_session(id, summary_data, directory)
213213
return self:_call('/session/' .. id .. '/summarize', 'POST', summary_data, { directory = directory })
214214
end
215215

216+
--- Fork an existing session at a specific message
217+
--- @param id string Session ID (required)
218+
--- @param fork_data {messageID?: string}|nil Fork data
219+
--- @param directory string|nil Directory path
220+
--- @return Promise<Session>
221+
function OpencodeApiClient:fork_session(id, fork_data, directory)
222+
return self:_call('/session/' .. id .. '/fork', 'POST', fork_data, { directory = directory })
223+
end
224+
216225
-- Message endpoints
217226

218227
--- List messages for a session

lua/opencode/config.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ M.defaults = {
1919
['<leader>oI'] = { 'open_input_new_session', desc = 'Open input (new session)' },
2020
['<leader>oo'] = { 'open_output', desc = 'Open output window' },
2121
['<leader>ot'] = { 'toggle_focus', desc = 'Toggle focus' },
22+
['<leader>oT'] = { 'timeline', desc = 'Session timeline' },
2223
['<leader>oq'] = { 'close', desc = 'Close Opencode window' },
2324
['<leader>os'] = { 'select_session', desc = 'Select session' },
2425
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
@@ -75,12 +76,17 @@ M.defaults = {
7576
delete_session = { '<C-d>' },
7677
new_session = { '<C-n>' },
7778
},
79+
timeline_picker = {
80+
undo = { '<C-u>', mode = { 'i', 'n' } },
81+
fork = { '<C-f>', mode = { 'i', 'n' } },
82+
},
7883
},
7984
ui = {
8085
position = 'right',
8186
input_position = 'bottom',
8287
window_width = 0.40,
8388
input_height = 0.15,
89+
picker_width = 100,
8490
display_model = true,
8591
display_context_size = true,
8692
display_cost = true,
@@ -173,6 +179,7 @@ M.defaults = {
173179
debug = {
174180
enabled = false,
175181
capture_streamed_events = false,
182+
show_ids = false,
176183
},
177184
prompt_guard = nil,
178185
}

lua/opencode/core.lua

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,24 @@ function M.select_session(parent_id)
2525
end
2626
return
2727
end
28-
-- clear the model so it can be set by the session. If it doesn't get set
29-
-- then core.get_model() will reset it to the default
30-
state.current_model = nil
31-
state.active_session = selected_session
32-
if state.windows then
33-
state.restore_points = {}
34-
-- Don't need to update either renderer because they subscribe to
35-
-- session changes
36-
ui.focus_input()
37-
else
38-
M.open()
39-
end
28+
M.switch_session(selected_session.id)
4029
end)
4130
end
4231

32+
function M.switch_session(session_id)
33+
local selected_session = session.get_by_id(session_id)
34+
-- clear the model so it can be set by the session. If it doesn't get set
35+
-- then core.get_model() will reset it to the default
36+
state.current_model = nil
37+
state.active_session = selected_session
38+
if state.windows then
39+
state.restore_points = {}
40+
ui.focus_input()
41+
else
42+
M.open()
43+
end
44+
end
45+
4346
---@param opts? OpenOpts
4447
function M.open(opts)
4548
opts = opts or { focus = 'input', new_session = false }

lua/opencode/git_review.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ function M.with_restore_point(restore_point_id, fn)
365365
item.files and #item.files or 0,
366366
item.deleted_files and #item.deleted_files or 0,
367367
item.id:sub(1, 8),
368-
utils.time_ago(item.created_at) or 'unknown',
368+
utils.format_time(item.created_at) or 'unknown',
369369
item.from_snapshot_id and item.from_snapshot_id:sub(1, 8) or 'none'
370370
)
371371
end,

lua/opencode/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function M.setup(opts)
88
local config = require('opencode.config')
99
config.setup(opts)
1010

11+
require('opencode.ui.highlight').setup()
1112
require('opencode.core').setup()
1213
require('opencode.api').setup()
1314
require('opencode.keymap').setup(config.keymap)

lua/opencode/types.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,16 @@
7272
---@field output_window OpencodeKeymapOutputWindow
7373
---@field permission OpencodeKeymapPermission
7474
---@field session_picker OpencodeSessionPickerKeymap
75+
---@field timeline_picker OpencodeTimelinePickerKeymap
7576

7677
---@class OpencodeSessionPickerKeymap
7778
---@field delete_session OpencodeKeymapEntry
7879
---@field new_session OpencodeKeymapEntry
7980

81+
---@class OpencodeTimelinePickerKeymap
82+
---@field undo OpencodeKeymapEntry
83+
---@field fork OpencodeKeymapEntry
84+
8085
---@class OpencodeCompletionFileSourcesConfig
8186
---@field enabled boolean
8287
---@field preferred_cli_tool 'server'|'fd'|'fdfind'|'rg'|'git'
@@ -95,6 +100,7 @@
95100
---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom')
96101
---@field window_width number
97102
---@field input_height number
103+
---@field picker_width number|nil # Default width for all pickers (nil uses current window width)
98104
---@field display_model boolean
99105
---@field display_context_size boolean
100106
---@field display_cost boolean
@@ -127,6 +133,7 @@
127133
---@class OpencodeDebugConfig
128134
---@field enabled boolean
129135
---@field capture_streamed_events boolean
136+
---@field show_ids boolean
130137

131138
--- @class OpencodeProviders
132139
--- @field [string] string[]

0 commit comments

Comments
 (0)