Skip to content

Commit cd3d912

Browse files
tweak(timeline): add a dot to the session timeline modal for better visual cue of session's revert point (sst#1978)
1 parent 75ed131 commit cd3d912

File tree

4 files changed

+109
-71
lines changed

4 files changed

+109
-71
lines changed

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export namespace Config {
229229
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
230230
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
231231
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
232+
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
232233
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
233234
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
234235
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),

packages/tui/internal/commands/command.go

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
107107
}
108108

109109
const (
110-
111110
SessionChildCycleCommand CommandName = "session_child_cycle"
112111
SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
113112
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
@@ -119,40 +118,40 @@ const (
119118
EditorOpenCommand CommandName = "editor_open"
120119
SessionNewCommand CommandName = "session_new"
121120
SessionListCommand CommandName = "session_list"
122-
SessionNavigationCommand CommandName = "session_navigation"
121+
SessionTimelineCommand CommandName = "session_timeline"
123122
SessionShareCommand CommandName = "session_share"
124123
SessionUnshareCommand CommandName = "session_unshare"
125-
SessionInterruptCommand CommandName = "session_interrupt"
126-
SessionCompactCommand CommandName = "session_compact"
127-
SessionExportCommand CommandName = "session_export"
128-
ToolDetailsCommand CommandName = "tool_details"
129-
ThinkingBlocksCommand CommandName = "thinking_blocks"
130-
ModelListCommand CommandName = "model_list"
131-
AgentListCommand CommandName = "agent_list"
132-
ModelCycleRecentCommand CommandName = "model_cycle_recent"
133-
ThemeListCommand CommandName = "theme_list"
134-
FileListCommand CommandName = "file_list"
135-
FileCloseCommand CommandName = "file_close"
136-
FileSearchCommand CommandName = "file_search"
137-
FileDiffToggleCommand CommandName = "file_diff_toggle"
138-
ProjectInitCommand CommandName = "project_init"
139-
InputClearCommand CommandName = "input_clear"
140-
InputPasteCommand CommandName = "input_paste"
141-
InputSubmitCommand CommandName = "input_submit"
142-
InputNewlineCommand CommandName = "input_newline"
143-
MessagesPageUpCommand CommandName = "messages_page_up"
144-
MessagesPageDownCommand CommandName = "messages_page_down"
145-
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
146-
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
147-
MessagesPreviousCommand CommandName = "messages_previous"
148-
MessagesNextCommand CommandName = "messages_next"
149-
MessagesFirstCommand CommandName = "messages_first"
150-
MessagesLastCommand CommandName = "messages_last"
151-
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
152-
MessagesCopyCommand CommandName = "messages_copy"
153-
MessagesUndoCommand CommandName = "messages_undo"
154-
MessagesRedoCommand CommandName = "messages_redo"
155-
AppExitCommand CommandName = "app_exit"
124+
SessionInterruptCommand CommandName = "session_interrupt"
125+
SessionCompactCommand CommandName = "session_compact"
126+
SessionExportCommand CommandName = "session_export"
127+
ToolDetailsCommand CommandName = "tool_details"
128+
ThinkingBlocksCommand CommandName = "thinking_blocks"
129+
ModelListCommand CommandName = "model_list"
130+
AgentListCommand CommandName = "agent_list"
131+
ModelCycleRecentCommand CommandName = "model_cycle_recent"
132+
ThemeListCommand CommandName = "theme_list"
133+
FileListCommand CommandName = "file_list"
134+
FileCloseCommand CommandName = "file_close"
135+
FileSearchCommand CommandName = "file_search"
136+
FileDiffToggleCommand CommandName = "file_diff_toggle"
137+
ProjectInitCommand CommandName = "project_init"
138+
InputClearCommand CommandName = "input_clear"
139+
InputPasteCommand CommandName = "input_paste"
140+
InputSubmitCommand CommandName = "input_submit"
141+
InputNewlineCommand CommandName = "input_newline"
142+
MessagesPageUpCommand CommandName = "messages_page_up"
143+
MessagesPageDownCommand CommandName = "messages_page_down"
144+
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
145+
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
146+
MessagesPreviousCommand CommandName = "messages_previous"
147+
MessagesNextCommand CommandName = "messages_next"
148+
MessagesFirstCommand CommandName = "messages_first"
149+
MessagesLastCommand CommandName = "messages_last"
150+
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
151+
MessagesCopyCommand CommandName = "messages_copy"
152+
MessagesUndoCommand CommandName = "messages_undo"
153+
MessagesRedoCommand CommandName = "messages_redo"
154+
AppExitCommand CommandName = "app_exit"
156155
)
157156

158157
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
@@ -216,10 +215,10 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
216215
Trigger: []string{"sessions", "resume", "continue"},
217216
},
218217
{
219-
Name: SessionNavigationCommand,
220-
Description: "jump to message",
218+
Name: SessionTimelineCommand,
219+
Description: "show session timeline",
221220
Keybindings: parseBindings("<leader>g"),
222-
Trigger: []string{"jump", "goto", "navigate"},
221+
Trigger: []string{"timeline", "history", "goto"},
223222
},
224223
{
225224
Name: SessionShareCommand,

packages/tui/internal/components/dialog/navigation.go renamed to packages/tui/internal/components/dialog/timeline.go

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import (
1818
"github.com/sst/opencode/internal/util"
1919
)
2020

21-
// NavigationDialog interface for the session navigation dialog
22-
type NavigationDialog interface {
21+
// TimelineDialog interface for the session timeline dialog
22+
type TimelineDialog interface {
2323
layout.Modal
2424
}
2525

@@ -34,34 +34,47 @@ type RestoreToMessageMsg struct {
3434
Index int
3535
}
3636

37-
// navigationItem represents a user message in the navigation list
38-
type navigationItem struct {
37+
// timelineItem represents a user message in the timeline list
38+
type timelineItem struct {
3939
messageID string
4040
content string
4141
timestamp time.Time
4242
index int // Index in the full message list
4343
toolCount int // Number of tools used in this message
4444
}
4545

46-
func (n navigationItem) Render(
46+
func (n timelineItem) Render(
4747
selected bool,
4848
width int,
4949
isFirstInViewport bool,
5050
baseStyle styles.Style,
51+
isCurrent bool,
5152
) string {
5253
t := theme.CurrentTheme()
5354
infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
5455
textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
5556

57+
// Add dot after timestamp if this is the current message - only apply color when not selected
58+
var dot string
59+
var dotVisualLen int
60+
if isCurrent {
61+
if selected {
62+
dot = "● "
63+
} else {
64+
dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
65+
}
66+
dotVisualLen = 2 // "● " is 2 characters wide
67+
}
68+
5669
// Format timestamp - only apply color when not selected
5770
var timeStr string
5871
var timeVisualLen int
5972
if selected {
60-
timeStr = n.timestamp.Format("15:04") + " "
61-
timeVisualLen = lipgloss.Width(timeStr)
73+
timeStr = n.timestamp.Format("15:04") + " " + dot
74+
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
6275
} else {
63-
timeStr = infoStyle(n.timestamp.Format("15:04") + " ")
64-
timeVisualLen = lipgloss.Width(timeStr)
76+
timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
77+
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
6578
}
6679

6780
// Tool count display (fixed width for alignment) - only apply color when not selected
@@ -78,7 +91,7 @@ func (n navigationItem) Render(
7891
}
7992

8093
// Calculate available space for content
81-
// Reserve space for: timestamp + space + toolInfo + padding + some buffer
94+
// Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
8295
reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
8396
contentWidth := max(width-reservedSpace, 8)
8497

@@ -135,23 +148,23 @@ func (n navigationItem) Render(
135148
return itemStyle.Render(text)
136149
}
137150

138-
func (n navigationItem) Selectable() bool {
151+
func (n timelineItem) Selectable() bool {
139152
return true
140153
}
141154

142-
type navigationDialog struct {
155+
type timelineDialog struct {
143156
width int
144157
height int
145158
modal *modal.Modal
146-
list list.List[navigationItem]
159+
list list.List[timelineItem]
147160
app *app.App
148161
}
149162

150-
func (n *navigationDialog) Init() tea.Cmd {
163+
func (n *timelineDialog) Init() tea.Cmd {
151164
return nil
152165
}
153166

154-
func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167+
func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155168
switch msg := msg.(type) {
156169
case tea.WindowSizeMsg:
157170
n.width = msg.Width
@@ -163,7 +176,7 @@ func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163176
// Handle navigation and immediately scroll to selected message
164177
var cmd tea.Cmd
165178
listModel, cmd := n.list.Update(msg)
166-
n.list = listModel.(list.List[navigationItem])
179+
n.list = listModel.(list.List[timelineItem])
167180

168181
// Get the newly selected item and scroll to it immediately
169182
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
@@ -191,11 +204,11 @@ func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191204

192205
var cmd tea.Cmd
193206
listModel, cmd := n.list.Update(msg)
194-
n.list = listModel.(list.List[navigationItem])
207+
n.list = listModel.(list.List[timelineItem])
195208
return n, cmd
196209
}
197210

198-
func (n *navigationDialog) Render(background string) string {
211+
func (n *timelineDialog) Render(background string) string {
199212
listView := n.list.View()
200213

201214
t := theme.CurrentTheme()
@@ -229,7 +242,7 @@ func (n *navigationDialog) Render(background string) string {
229242
return n.modal.Render(content, background)
230243
}
231244

232-
func (n *navigationDialog) Close() tea.Cmd {
245+
func (n *timelineDialog) Close() tea.Cmd {
233246
return nil
234247
}
235248

@@ -268,17 +281,17 @@ func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
268281
return count
269282
}
270283

271-
// NewNavigationDialog creates a new session navigation dialog
272-
func NewNavigationDialog(app *app.App) NavigationDialog {
273-
var items []navigationItem
284+
// NewTimelineDialog creates a new session timeline dialog
285+
func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
286+
var items []timelineItem
274287

275288
// Filter to only user messages and extract relevant info
276289
for i, message := range app.Messages {
277290
if userMsg, ok := message.Info.(opencode.UserMessage); ok {
278291
preview := extractMessagePreview(message.Parts)
279292
toolCount := countToolsInResponse(app.Messages, i)
280293

281-
items = append(items, navigationItem{
294+
items = append(items, timelineItem{
282295
messageID: userMsg.ID,
283296
content: preview,
284297
timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
@@ -290,25 +303,50 @@ func NewNavigationDialog(app *app.App) NavigationDialog {
290303

291304
listComponent := list.NewListComponent(
292305
list.WithItems(items),
293-
list.WithMaxVisibleHeight[navigationItem](12),
294-
list.WithFallbackMessage[navigationItem]("No user messages in this session"),
295-
list.WithAlphaNumericKeys[navigationItem](true),
306+
list.WithMaxVisibleHeight[timelineItem](12),
307+
list.WithFallbackMessage[timelineItem]("No user messages in this session"),
308+
list.WithAlphaNumericKeys[timelineItem](true),
296309
list.WithRenderFunc(
297-
func(item navigationItem, selected bool, width int, baseStyle styles.Style) string {
298-
return item.Render(selected, width, false, baseStyle)
310+
func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
311+
// Determine if this item is the current message for the session
312+
isCurrent := false
313+
if app.Session.Revert.MessageID != "" {
314+
// When reverted, Session.Revert.MessageID contains the NEXT user message ID
315+
// So we need to find the previous user message to highlight the correct one
316+
for i, navItem := range items {
317+
if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
318+
// Found the next message, so the previous one is current
319+
isCurrent = item.messageID == items[i-1].messageID
320+
break
321+
}
322+
}
323+
} else if len(app.Messages) > 0 {
324+
// If not reverted, highlight the last user message
325+
lastUserMsgID := ""
326+
for i := len(app.Messages) - 1; i >= 0; i-- {
327+
if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
328+
lastUserMsgID = userMsg.ID
329+
break
330+
}
331+
}
332+
isCurrent = item.messageID == lastUserMsgID
333+
}
334+
// Only show the dot if undo/redo/restore is available
335+
showDot := app.Session.Revert.MessageID != ""
336+
return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
299337
},
300338
),
301-
list.WithSelectableFunc(func(item navigationItem) bool {
339+
list.WithSelectableFunc(func(item timelineItem) bool {
302340
return true
303341
}),
304342
)
305343
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
306344

307-
return &navigationDialog{
345+
return &timelineDialog{
308346
list: listComponent,
309347
app: app,
310348
modal: modal.New(
311-
modal.WithTitle("Jump to Message"),
349+
modal.WithTitle("Session Timeline"),
312350
modal.WithMaxWidth(layout.Current.Container.Width-8),
313351
),
314352
}

packages/tui/internal/tui/tui.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -728,8 +728,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
728728
case "/tui/open-sessions":
729729
sessionDialog := dialog.NewSessionDialog(a.app)
730730
a.modal = sessionDialog
731-
case "/tui/open-navigation":
732-
navigationDialog := dialog.NewNavigationDialog(a.app)
731+
case "/tui/open-timeline":
732+
navigationDialog := dialog.NewTimelineDialog(a.app)
733733
a.modal = navigationDialog
734734
case "/tui/open-themes":
735735
themeDialog := dialog.NewThemeDialog()
@@ -1146,11 +1146,11 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
11461146
case commands.SessionListCommand:
11471147
sessionDialog := dialog.NewSessionDialog(a.app)
11481148
a.modal = sessionDialog
1149-
case commands.SessionNavigationCommand:
1149+
case commands.SessionTimelineCommand:
11501150
if a.app.Session.ID == "" {
11511151
return a, toast.NewErrorToast("No active session")
11521152
}
1153-
navigationDialog := dialog.NewNavigationDialog(a.app)
1153+
navigationDialog := dialog.NewTimelineDialog(a.app)
11541154
a.modal = navigationDialog
11551155
case commands.SessionShareCommand:
11561156
if a.app.Session.ID == "" {

0 commit comments

Comments
 (0)