summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/dialog
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/dialog
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz
opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages/tui/internal/components/dialog')
-rw-r--r--packages/tui/internal/components/dialog/agents.go452
-rw-r--r--packages/tui/internal/components/dialog/complete.go314
-rw-r--r--packages/tui/internal/components/dialog/help.go80
-rw-r--r--packages/tui/internal/components/dialog/models.go458
-rw-r--r--packages/tui/internal/components/dialog/search.go255
-rw-r--r--packages/tui/internal/components/dialog/session.go400
-rw-r--r--packages/tui/internal/components/dialog/theme.go132
-rw-r--r--packages/tui/internal/components/dialog/timeline.go353
8 files changed, 0 insertions, 2444 deletions
diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go
deleted file mode 100644
index c2cbd6450..000000000
--- a/packages/tui/internal/components/dialog/agents.go
+++ /dev/null
@@ -1,452 +0,0 @@
-package dialog
-
-import (
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-const (
- numVisibleAgents = 10
- minAgentDialogWidth = 40
- maxAgentDialogWidth = 60
- maxDescriptionLength = 60
- maxRecentAgents = 5
-)
-
-// AgentDialog interface for the agent selection dialog
-type AgentDialog interface {
- layout.Modal
-}
-
-type agentDialog struct {
- app *app.App
- allAgents []agentSelectItem
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-// agentSelectItem combines the visual improvements with code patterns
-type agentSelectItem struct {
- name string
- displayName string
- description string
- mode string // "primary", "subagent", "all"
- isCurrent bool
- agentIndex int
- agent opencode.Agent // Keep original agent for compatibility
-}
-
-func (a agentSelectItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- // Use agent color for highlighting when selected (visual improvement)
- agentColor := util.GetAgentColor(a.agentIndex)
- itemStyle = itemStyle.Foreground(agentColor)
- }
-
- descStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- // Calculate available width (accounting for padding and margins)
- availableWidth := width - 2 // Account for left padding
-
- agentName := a.displayName
-
- // Determine if agent is built-in or custom using the agent's builtIn field
- var displayText string
- if a.agent.BuiltIn {
- displayText = "(built-in)"
- } else {
- if a.description != "" {
- displayText = a.description
- } else {
- displayText = "(user)"
- }
- }
-
- separator := " - "
-
- // Calculate how much space we have for the description (visual improvement)
- nameAndSeparatorLength := len(agentName) + len(separator)
- descriptionMaxLength := availableWidth - nameAndSeparatorLength
-
- // Cap description length to the maximum allowed
- if descriptionMaxLength > maxDescriptionLength {
- descriptionMaxLength = maxDescriptionLength
- }
-
- // Truncate description if it's too long (visual improvement)
- if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
- displayText = displayText[:descriptionMaxLength-3] + "..."
- }
-
- namePart := itemStyle.Render(agentName)
- descPart := descStyle.Render(separator + displayText)
- combinedText := namePart + descPart
-
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Width(width).
- Render(combinedText)
-}
-
-func (a agentSelectItem) Selectable() bool {
- return true
-}
-
-type agentKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var agentKeys = agentKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select agent"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (a *agentDialog) Init() tea.Cmd {
- a.setupAllAgents()
- return a.searchDialog.Init()
-}
-
-func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- a.width = msg.Width
- a.height = msg.Height
- a.searchDialog.SetWidth(a.dialogWidth)
- a.searchDialog.SetHeight(msg.Height)
-
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(agentSelectItem); ok {
- if !item.isCurrent {
- // Switch to selected agent (using their better pattern)
- return a, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
- )
- }
- }
- return a, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return a, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(agentSelectItem); ok {
- if a.isAgentInRecentSection(item, msg.Index) {
- a.app.State.RemoveAgentFromRecentlyUsed(item.name)
- items := a.buildDisplayList(a.searchDialog.GetQuery())
- a.searchDialog.SetItems(items)
- return a, a.app.SaveState()
- }
- }
- return a, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := a.buildDisplayList(msg.Query)
- a.searchDialog.SetItems(items)
- return a, nil
- }
-
- updatedDialog, cmd := a.searchDialog.Update(msg)
- a.searchDialog = updatedDialog.(*SearchDialog)
- return a, cmd
-}
-
-func (a *agentDialog) SetSize(width, height int) {
- a.width = width
- a.height = height
-}
-
-func (a *agentDialog) View() string {
- return a.searchDialog.View()
-}
-
-func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
- maxWidth := minAgentDialogWidth
-
- for _, agent := range agents {
- // Calculate the width needed for this item: "AgentName - Description" (visual improvement)
- itemWidth := len(agent.displayName)
-
- if agent.agent.BuiltIn {
- itemWidth += len("(built-in)") + 3 // " - "
- } else {
- if agent.description != "" {
- descLength := len(agent.description)
- if descLength > maxDescriptionLength {
- descLength = maxDescriptionLength
- }
- itemWidth += descLength + 3 // " - "
- } else {
- itemWidth += len("(user)") + 3 // " - "
- }
- }
-
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- maxWidth = min(maxWidth, maxAgentDialogWidth)
- return maxWidth
-}
-
-func (a *agentDialog) setupAllAgents() {
- currentAgentName := a.app.Agent().Name
-
- // Build agent items from app.Agents (no API call needed) - their pattern
- a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
- for i, agent := range a.app.Agents {
- if agent.Mode == "subagent" {
- continue // Skip subagents entirely
- }
- isCurrent := agent.Name == currentAgentName
-
- // Create display name (capitalize first letter)
- displayName := strings.Title(agent.Name)
-
- a.allAgents = append(a.allAgents, agentSelectItem{
- name: agent.Name,
- displayName: displayName,
- description: agent.Description, // Keep for search but don't use in display
- mode: string(agent.Mode),
- isCurrent: isCurrent,
- agentIndex: i,
- agent: agent, // Keep original for compatibility
- })
- }
-
- a.sortAgents()
-
- // Calculate optimal width based on all agents (visual improvement)
- a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
-
- // Ensure minimum width to prevent textinput issues
- a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
-
- a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
- a.searchDialog.SetWidth(a.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := a.buildDisplayList("")
- a.searchDialog.SetItems(items)
-}
-
-func (a *agentDialog) sortAgents() {
- sort.Slice(a.allAgents, func(i, j int) bool {
- agentA := a.allAgents[i]
- agentB := a.allAgents[j]
-
- // Current agent goes first (your preference)
- if agentA.name == a.app.Agent().Name {
- return true
- }
- if agentB.name == a.app.Agent().Name {
- return false
- }
-
- // Alphabetical order for all other agents
- return agentA.name < agentB.name
- })
-}
-
-// buildDisplayList creates the list items based on search query
-func (a *agentDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return a.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent agents section and alphabetical list (their pattern)
- return a.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (a *agentDialog) buildSearchResults(query string) []list.Item {
- agentNames := []string{}
- agentMap := make(map[string]agentSelectItem)
-
- for _, agent := range a.allAgents {
- // Only include non-subagents in search
- if agent.mode == "subagent" {
- continue
- }
- searchStr := agent.name
- agentNames = append(agentNames, searchStr)
- agentMap[searchStr] = agent
- }
-
- matches := fuzzy.RankFindFold(query, agentNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenAgents := make(map[string]bool)
-
- for _, match := range matches {
- agent := agentMap[match.Target]
- // Create a unique key to avoid duplicates
- key := agent.name
- if seenAgents[key] {
- continue
- }
- seenAgents[key] = true
- items = append(items, agent)
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
-func (a *agentDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section (their pattern)
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, agent := range recentAgents {
- items = append(items, agent)
- }
- }
-
- // Create map of recent agent names for filtering
- recentAgentNames := make(map[string]bool)
- for _, recent := range recentAgents {
- recentAgentNames[recent.name] = true
- }
-
- // Only show non-subagents (primary/user) in the main section
- mainAgents := make([]agentSelectItem, 0)
- for _, agent := range a.allAgents {
- if !recentAgentNames[agent.name] {
- mainAgents = append(mainAgents, agent)
- }
- }
-
- // Sort main agents alphabetically
- sort.Slice(mainAgents, func(i, j int) bool {
- return mainAgents[i].name < mainAgents[j].name
- })
-
- // Add main agents section
- if len(mainAgents) > 0 {
- items = append(items, list.HeaderItem("Agents"))
- for _, agent := range mainAgents {
- items = append(items, agent)
- }
- }
-
- return items
-}
-
-func (a *agentDialog) Render(background string) string {
- return a.modal.Render(a.View(), background)
-}
-
-func (a *agentDialog) Close() tea.Cmd {
- return nil
-}
-
-// getRecentAgents returns the most recently used agents (their pattern)
-func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
- var recentAgents []agentSelectItem
-
- // Get recent agents from app state
- for _, usage := range a.app.State.RecentlyUsedAgents {
- if len(recentAgents) >= limit {
- break
- }
-
- // Find the corresponding agent
- for _, agent := range a.allAgents {
- if agent.name == usage.AgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- // If no recent agents, use the current agent
- if len(recentAgents) == 0 {
- currentAgentName := a.app.Agent().Name
- for _, agent := range a.allAgents {
- if agent.name == currentAgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- return recentAgents
-}
-
-func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if a.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
- if index >= 1 && index <= len(recentAgents) {
- if index-1 < len(recentAgents) {
- recentAgent := recentAgents[index-1]
- return recentAgent.name == agent.name
- }
- }
-
- return false
-}
-
-func NewAgentDialog(app *app.App) AgentDialog {
- dialog := &agentDialog{
- app: app,
- }
-
- dialog.setupAllAgents()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Agent"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
deleted file mode 100644
index 4e890b081..000000000
--- a/packages/tui/internal/components/dialog/complete.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package dialog
-
-import (
- "log/slog"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textarea"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type CompletionSelectedMsg struct {
- Item completions.CompletionSuggestion
- SearchString string
-}
-
-type CompletionDialogCompleteItemMsg struct {
- Value string
-}
-
-type CompletionDialogCloseMsg struct{}
-
-type CompletionDialog interface {
- tea.Model
- tea.ViewModel
- SetWidth(width int)
- IsEmpty() bool
-}
-
-type completionDialogComponent struct {
- query string
- providers []completions.CompletionProvider
- width int
- height int
- pseudoSearchTextArea textarea.Model
- list list.List[completions.CompletionSuggestion]
- trigger string
-}
-
-type completionDialogKeyMap struct {
- Complete key.Binding
- Cancel key.Binding
-}
-
-var completionDialogKeys = completionDialogKeyMap{
- Complete: key.NewBinding(
- key.WithKeys("tab", "enter", "right"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
- ),
-}
-
-func (c *completionDialogComponent) Init() tea.Cmd {
- return nil
-}
-
-func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
- return func() tea.Msg {
- // Collect results from all providers and preserve provider order
- type providerItems struct {
- idx int
- items []completions.CompletionSuggestion
- }
-
- itemsByProvider := make([]providerItems, 0, len(c.providers))
- providersWithResults := 0
-
- for idx, provider := range c.providers {
- items, err := provider.GetChildEntries(query)
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- if len(items) > 0 {
- providersWithResults++
- itemsByProvider = append(itemsByProvider, providerItems{idx: idx, items: items})
- }
- }
-
- // If there's a query, fuzzy-rank within each provider, then concatenate by provider order
- if query != "" && providersWithResults > 1 {
- t := theme.CurrentTheme()
- baseStyle := styles.NewStyle().Background(t.BackgroundElement())
-
- // Ensure stable provider order just in case
- sort.SliceStable(
- itemsByProvider,
- func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
- )
-
- final := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- // Build display values for fuzzy matching within this provider
- displayValues := make([]string, len(entry.items))
- for i, item := range entry.items {
- displayValues[i] = item.Display(baseStyle)
- }
-
- matches := fuzzy.RankFindFold(query, displayValues)
- sort.Sort(matches)
-
- // Reorder items for this provider based on fuzzy ranking
- ranked := make([]completions.CompletionSuggestion, 0, len(matches))
- for _, m := range matches {
- ranked = append(ranked, entry.items[m.OriginalIndex])
- }
- final = append(final, ranked...)
- }
-
- return final
- }
-
- // No query or no results: just concatenate in provider order
- all := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- all = append(all, entry.items...)
- }
- return all
- }
-}
-func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case []completions.CompletionSuggestion:
- c.list.SetItems(msg)
- case tea.KeyMsg:
- if c.pseudoSearchTextArea.Focused() {
- if !key.Matches(msg, completionDialogKeys.Complete) {
- var cmd tea.Cmd
- c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
- cmds = append(cmds, cmd)
-
- fullValue := c.pseudoSearchTextArea.Value()
- query := strings.TrimPrefix(fullValue, c.trigger)
-
- if query != c.query {
- c.query = query
- cmds = append(cmds, c.getAllCompletions(query))
- }
-
- u, cmd := c.list.Update(msg)
- c.list = u.(list.List[completions.CompletionSuggestion])
- cmds = append(cmds, cmd)
- }
-
- switch {
- case key.Matches(msg, completionDialogKeys.Complete):
- item, i := c.list.GetSelectedItem()
- if i == -1 {
- return c, nil
- }
- return c, c.complete(item)
- case key.Matches(msg, completionDialogKeys.Cancel):
- value := c.pseudoSearchTextArea.Value()
- width := lipgloss.Width(value)
- triggerWidth := lipgloss.Width(c.trigger)
-
- if msg.String() == "space" || msg.String() == " " {
- item, i := c.list.GetSelectedItem()
- if i > -1 {
- return c, c.complete(item)
- }
- // If no exact match, close the dialog
- return c, c.close()
- }
-
- // Only close on backspace when there are no characters left, unless we're back to just the trigger
- if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
- return c, c.close()
- }
- }
-
- return c, tea.Batch(cmds...)
- } else {
- cmds = append(cmds, c.getAllCompletions(""))
- cmds = append(cmds, c.pseudoSearchTextArea.Focus())
- return c, tea.Batch(cmds...)
- }
- }
-
- return c, tea.Batch(cmds...)
-}
-
-func (c *completionDialogComponent) View() string {
- t := theme.CurrentTheme()
- c.list.SetMaxWidth(c.width)
-
- return styles.NewStyle().
- Padding(0, 1).
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderForeground(t.Border()).
- BorderBackground(t.Background()).
- Width(c.width).
- Render(c.list.View())
-}
-
-func (c *completionDialogComponent) SetWidth(width int) {
- c.width = width
-}
-
-func (c *completionDialogComponent) IsEmpty() bool {
- return c.list.IsEmpty()
-}
-
-func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
- value := c.pseudoSearchTextArea.Value()
- return tea.Batch(
- util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- Item: item,
- }),
- c.close(),
- )
-}
-
-func (c *completionDialogComponent) close() tea.Cmd {
- c.pseudoSearchTextArea.Reset()
- c.pseudoSearchTextArea.Blur()
- return util.CmdHandler(CompletionDialogCloseMsg{})
-}
-
-func NewCompletionDialogComponent(
- trigger string,
- providers ...completions.CompletionProvider,
-) CompletionDialog {
- ti := textarea.New()
- ti.SetValue(trigger)
-
- // Use a generic empty message if we have multiple providers
- emptyMessage := "no matching items"
- if len(providers) == 1 {
- emptyMessage = providers[0].GetEmptyMessage()
- }
-
- // Define render function for completion suggestions
- renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
- style := baseStyle
-
- if selected {
- style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
- } else {
- style = style.Background(t.BackgroundElement()).Foreground(t.Text())
- }
-
- // The item.Display string already has any inline colors from the provider
- truncatedStr := truncate.String(item.Display(style), uint(width-4))
- return style.Width(width - 4).Render(truncatedStr)
- }
-
- // Define selectable function - all completion suggestions are selectable
- selectableFunc := func(item completions.CompletionSuggestion) bool {
- return true
- }
-
- li := list.NewListComponent(
- list.WithItems([]completions.CompletionSuggestion{}),
- list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
- list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
- list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
- list.WithRenderFunc(renderFunc),
- list.WithSelectableFunc(selectableFunc),
- )
-
- c := &completionDialogComponent{
- query: "",
- providers: providers,
- pseudoSearchTextArea: ti,
- list: li,
- trigger: trigger,
- }
-
- // Load initial items from all providers
- go func() {
- allItems := make([]completions.CompletionSuggestion, 0)
- for _, provider := range providers {
- items, err := provider.GetChildEntries("")
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- allItems = append(allItems, items...)
- }
- li.SetItems(allItems)
- }()
-
- return c
-}
diff --git a/packages/tui/internal/components/dialog/help.go b/packages/tui/internal/components/dialog/help.go
deleted file mode 100644
index 15931724b..000000000
--- a/packages/tui/internal/components/dialog/help.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/app"
- commandsComponent "github.com/sst/opencode/internal/components/commands"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/viewport"
-)
-
-type helpDialog struct {
- width int
- height int
- modal *modal.Modal
- app *app.App
- commandsComponent commandsComponent.CommandsComponent
- viewport viewport.Model
-}
-
-func (h *helpDialog) Init() tea.Cmd {
- return h.viewport.Init()
-}
-
-func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = msg.Width
- h.height = msg.Height
- // Set viewport size with some padding for the modal, but cap at reasonable width
- maxWidth := min(80, msg.Width-8)
- h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
- h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
- }
-
- // Update viewport content
- h.viewport.SetContent(h.commandsComponent.View())
-
- // Update viewport
- var vpCmd tea.Cmd
- h.viewport, vpCmd = h.viewport.Update(msg)
- cmds = append(cmds, vpCmd)
-
- return h, tea.Batch(cmds...)
-}
-
-func (h *helpDialog) View() string {
- t := theme.CurrentTheme()
- h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
- return h.viewport.View()
-}
-
-func (h *helpDialog) Render(background string) string {
- return h.modal.Render(h.View(), background)
-}
-
-func (h *helpDialog) Close() tea.Cmd {
- return nil
-}
-
-type HelpDialog interface {
- layout.Modal
-}
-
-func NewHelpDialog(app *app.App) HelpDialog {
- vp := viewport.New(viewport.WithHeight(12))
- return &helpDialog{
- app: app,
- commandsComponent: commandsComponent.New(app,
- commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
- commandsComponent.WithShowAll(true),
- commandsComponent.WithKeybinds(true),
- ),
- modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
- viewport: vp,
- }
-}
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
deleted file mode 100644
index e30a1068e..000000000
--- a/packages/tui/internal/components/dialog/models.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package dialog
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-const (
- numVisibleModels = 10
- minDialogWidth = 40
- maxDialogWidth = 80
- maxRecentModels = 5
-)
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
- layout.Modal
-}
-
-type modelDialog struct {
- app *app.App
- allModels []ModelWithProvider
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-type ModelWithProvider struct {
- Model opencode.Model
- Provider opencode.Provider
-}
-
-// modelItem is a custom list item for model selections
-type modelItem struct {
- model ModelWithProvider
-}
-
-func (m modelItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- itemStyle = itemStyle.Foreground(t.Primary())
- }
-
- providerStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- modelPart := itemStyle.Render(m.model.Model.Name)
- providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
-
- combinedText := modelPart + providerPart
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Render(combinedText)
-}
-
-func (m modelItem) Selectable() bool {
- return true
-}
-
-type modelKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var modelKeys = modelKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select model"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (m *modelDialog) Init() tea.Cmd {
- m.setupAllModels()
- return m.searchDialog.Init()
-}
-
-func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(modelItem); ok {
- return m, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(
- app.ModelSelectedMsg{
- Provider: item.model.Provider,
- Model: item.model.Model,
- }),
- )
- }
- return m, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return m, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(modelItem); ok {
- if m.isModelInRecentSection(item.model, msg.Index) {
- m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
- items := m.buildDisplayList(m.searchDialog.GetQuery())
- m.searchDialog.SetItems(items)
- return m, m.app.SaveState()
- }
- }
- return m, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := m.buildDisplayList(msg.Query)
- m.searchDialog.SetItems(items)
- return m, nil
-
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- m.searchDialog.SetWidth(m.dialogWidth)
- m.searchDialog.SetHeight(msg.Height)
- }
-
- updatedDialog, cmd := m.searchDialog.Update(msg)
- m.searchDialog = updatedDialog.(*SearchDialog)
- return m, cmd
-}
-
-func (m *modelDialog) View() string {
- return m.searchDialog.View()
-}
-
-func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
- maxWidth := minDialogWidth
-
- for _, model := range models {
- // Calculate the width needed for this item: "ModelName (ProviderName)"
- // Add 4 for the parentheses, space, and some padding
- itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- if maxWidth > maxDialogWidth {
- maxWidth = maxDialogWidth
- }
-
- return maxWidth
-}
-
-func (m *modelDialog) setupAllModels() {
- providers, _ := m.app.ListProviders(context.Background())
-
- m.allModels = make([]ModelWithProvider, 0)
- for _, provider := range providers {
- for _, model := range provider.Models {
- m.allModels = append(m.allModels, ModelWithProvider{
- Model: model,
- Provider: provider,
- })
- }
- }
-
- m.sortModels()
-
- // Calculate optimal width based on all models
- m.dialogWidth = m.calculateOptimalWidth(m.allModels)
-
- // Initialize search dialog
- m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
- m.searchDialog.SetWidth(m.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := m.buildDisplayList("")
- m.searchDialog.SetItems(items)
-}
-
-func (m *modelDialog) sortModels() {
- sort.Slice(m.allModels, func(i, j int) bool {
- modelA := m.allModels[i]
- modelB := m.allModels[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // If both have usage times, sort by most recent first
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
-
- // If only one has usage time, it goes first
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // If neither has usage time, sort by release date desc if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- // If only one has release date, it goes first
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
- return true
- }
- if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
- return false
- }
-
- // If neither has usage time nor release date, fall back to alphabetical sorting
- return modelA.Model.Name < modelB.Model.Name
- })
-}
-
-func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
- if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
- return parsed
- }
-
- return time.Time{}
-}
-
-func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
- for _, usage := range m.app.State.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- return usage.LastUsed
- }
- }
- return time.Time{}
-}
-
-// buildDisplayList creates the list items based on search query
-func (m *modelDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return m.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent section and provider groups
- return m.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (m *modelDialog) buildSearchResults(query string) []list.Item {
- type modelMatch struct {
- model ModelWithProvider
- score int
- }
-
- modelNames := []string{}
- modelMap := make(map[string]ModelWithProvider)
-
- // Create search strings and perform fuzzy matching
- for _, model := range m.allModels {
- searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
-
- searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
- }
-
- matches := fuzzy.RankFindFold(query, modelNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenModels := make(map[string]bool)
-
- for _, match := range matches {
- model := modelMap[match.Target]
- // Create a unique key to avoid duplicates
- // Include name to handle custom models with same ID but different names
- key := fmt.Sprintf("%s:%s:%s", model.Provider.ID, model.Model.ID, model.Model.Name)
- if seenModels[key] {
- continue
- }
- seenModels[key] = true
- items = append(items, modelItem{model: model})
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent section and provider groups
-func (m *modelDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, model := range recentModels {
- items = append(items, modelItem{model: model})
- }
- }
-
- // Group models by provider
- providerGroups := make(map[string][]ModelWithProvider)
- for _, model := range m.allModels {
- providerName := model.Provider.Name
- providerGroups[providerName] = append(providerGroups[providerName], model)
- }
-
- // Get sorted provider names for consistent order
- var providerNames []string
- for name := range providerGroups {
- providerNames = append(providerNames, name)
- }
- sort.Strings(providerNames)
-
- // Add provider groups
- for _, providerName := range providerNames {
- models := providerGroups[providerName]
-
- // Sort models within provider group
- sort.Slice(models, func(i, j int) bool {
- modelA := models[i]
- modelB := models[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // Sort by usage time first, then by release date, then alphabetically
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // Sort by release date if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- return modelA.Model.Name < modelB.Model.Name
- })
-
- // Add provider header
- items = append(items, list.HeaderItem(providerName))
-
- // Add models in this provider group
- for _, model := range models {
- items = append(items, modelItem{model: model})
- }
- }
-
- return items
-}
-
-// getRecentModels returns the most recently used models
-func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
- var recentModels []ModelWithProvider
-
- // Get recent models from app state
- for _, usage := range m.app.State.RecentlyUsedModels {
- if len(recentModels) >= limit {
- break
- }
-
- // Find the corresponding model
- for _, model := range m.allModels {
- if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
- recentModels = append(recentModels, model)
- break
- }
- }
- }
-
- return recentModels
-}
-
-func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if m.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
- if index >= 1 && index <= len(recentModels) {
- if index-1 < len(recentModels) {
- recentModel := recentModels[index-1]
- return recentModel.Provider.ID == model.Provider.ID &&
- recentModel.Model.ID == model.Model.ID
- }
- }
-
- return false
-}
-
-func (m *modelDialog) Render(background string) string {
- return m.modal.Render(m.View(), background)
-}
-
-func (s *modelDialog) Close() tea.Cmd {
- return nil
-}
-
-func NewModelDialog(app *app.App) ModelDialog {
- dialog := &modelDialog{
- app: app,
- }
-
- dialog.setupAllModels()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Model"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go
deleted file mode 100644
index b8fefd8b9..000000000
--- a/packages/tui/internal/components/dialog/search.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// SearchQueryChangedMsg is emitted when the search query changes
-type SearchQueryChangedMsg struct {
- Query string
-}
-
-// SearchSelectionMsg is emitted when an item is selected
-type SearchSelectionMsg struct {
- Item any
- Index int
-}
-
-// SearchCancelledMsg is emitted when the search is cancelled
-type SearchCancelledMsg struct{}
-
-// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
-type SearchRemoveItemMsg struct {
- Item any
- Index int
-}
-
-// SearchDialog is a reusable component that combines a text input with a list
-type SearchDialog struct {
- textInput textinput.Model
- list list.List[list.Item]
- width int
- height int
- focused bool
-}
-
-type searchKeyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Escape key.Binding
- Remove key.Binding
-}
-
-var searchKeys = searchKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next item"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
- Remove: key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("ctrl+x", "remove from recent"),
- ),
-}
-
-// NewSearchDialog creates a new SearchDialog
-func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundElement()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ti := textinput.New()
- ti.Placeholder = placeholder
- ti.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Blurred.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
- ti.Styles.Cursor.Color = t.Primary()
- ti.VirtualCursor = true
-
- ti.Prompt = " "
- ti.CharLimit = -1
- ti.Focus()
-
- emptyList := list.NewListComponent(
- list.WithItems([]list.Item{}),
- list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
- list.WithFallbackMessage[list.Item](" No items"),
- list.WithAlphaNumericKeys[list.Item](false),
- list.WithRenderFunc(
- func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- return &SearchDialog{
- textInput: ti,
- list: emptyList,
- focused: true,
- }
-}
-
-func (s *SearchDialog) Init() tea.Cmd {
- return textinput.Blink
-}
-
-func (s *SearchDialog) updateTextInput(msg tea.Msg) []tea.Cmd {
- var cmds []tea.Cmd
- oldValue := s.textInput.Value()
- var cmd tea.Cmd
- s.textInput, cmd = s.textInput.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- if newValue := s.textInput.Value(); newValue != oldValue {
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: newValue}
- })
- }
- return cmds
-}
-
-func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.PasteMsg, tea.ClipboardMsg:
- cmds = append(cmds, s.updateTextInput(msg)...)
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c":
- value := s.textInput.Value()
- if value == "" {
- return s, nil
- }
- s.textInput.Reset()
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: ""}
- })
- }
-
- switch {
- case key.Matches(msg, searchKeys.Escape):
- return s, func() tea.Msg { return SearchCancelledMsg{} }
-
- case key.Matches(msg, searchKeys.Enter):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchSelectionMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Remove):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Up):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- case key.Matches(msg, searchKeys.Down):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- default:
- cmds = append(cmds, s.updateTextInput(msg)...)
- }
- }
-
- return s, tea.Batch(cmds...)
-}
-
-func (s *SearchDialog) View() string {
- s.list.SetMaxWidth(s.width)
- listView := s.list.View()
- listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
- textinput := s.textInput.View()
- return textinput + "\n\n" + listView
-}
-
-// SetWidth sets the width of the search dialog
-func (s *SearchDialog) SetWidth(width int) {
- s.width = width
- s.textInput.SetWidth(width - 2) // Account for padding and borders
-}
-
-// SetHeight sets the height of the search dialog
-func (s *SearchDialog) SetHeight(height int) {
- s.height = height
-}
-
-// SetItems updates the list items
-func (s *SearchDialog) SetItems(items []list.Item) {
- s.list.SetItems(items)
-}
-
-// GetQuery returns the current search query
-func (s *SearchDialog) GetQuery() string {
- return s.textInput.Value()
-}
-
-// SetQuery sets the search query
-func (s *SearchDialog) SetQuery(query string) {
- s.textInput.SetValue(query)
-}
-
-// Focus focuses the search dialog
-func (s *SearchDialog) Focus() {
- s.focused = true
- s.textInput.Focus()
-}
-
-// Blur removes focus from the search dialog
-func (s *SearchDialog) Blur() {
- s.focused = false
- s.textInput.Blur()
-}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
deleted file mode 100644
index a1700c896..000000000
--- a/packages/tui/internal/components/dialog/session.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package dialog
-
-import (
- "context"
- "strings"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
- layout.Modal
-}
-
-// sessionItem is a custom list item for sessions that can show delete confirmation
-type sessionItem struct {
- title string
- isDeleteConfirming bool
- isCurrentSession bool
-}
-
-func (s sessionItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- var text string
- if s.isDeleteConfirming {
- text = "Press again to confirm delete"
- } else {
- if s.isCurrentSession {
- text = "● " + s.title
- } else {
- text = s.title
- }
- }
-
- truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- if s.isDeleteConfirming {
- // Red background for delete confirmation
- itemStyle = baseStyle.
- Background(t.Error()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Different style for current session when selected
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1).
- Bold(true)
- } else {
- // Normal selection
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- }
- } else {
- if s.isDeleteConfirming {
- // Red text for delete confirmation when not selected
- itemStyle = baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Highlight current session when not selected
- itemStyle = baseStyle.
- Foreground(t.Primary()).
- PaddingLeft(1).
- Bold(true)
- } else {
- itemStyle = baseStyle.
- PaddingLeft(1)
- }
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s sessionItem) Selectable() bool {
- return true
-}
-
-type sessionDialog struct {
- width int
- height int
- modal *modal.Modal
- sessions []opencode.Session
- list list.List[sessionItem]
- app *app.App
- deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
- renameMode bool
- renameInput textinput.Model
- renameIndex int // index of session being renamed
-}
-
-func (s *sessionDialog) Init() tea.Cmd {
- return nil
-}
-
-func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.width = msg.Width
- s.height = msg.Height
- s.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- if s.renameMode {
- switch msg.String() {
- case "enter":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
- newTitle := s.renameInput.Value()
- if strings.TrimSpace(newTitle) != "" {
- sessionToUpdate := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- ctx := context.Background()
- err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
- if err != nil {
- return toast.NewErrorToast("Failed to rename session: " + err.Error())()
- }
- s.sessions[idx].Title = newTitle
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return toast.NewSuccessToast("Session renamed successfully")()
- },
- )
- }
- }
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return s, nil
- default:
- var cmd tea.Cmd
- s.renameInput, cmd = s.renameInput.Update(msg)
- return s, cmd
- }
- } else {
- switch msg.String() {
- case "enter":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- selectedSession := s.sessions[idx]
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
- )
- }
- case "n":
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionClearedMsg{}),
- )
- case "r":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- s.renameMode = true
- s.renameIndex = idx
- s.setupRenameInput(s.sessions[idx].Title)
- s.modal.SetTitle("Rename Session")
- s.updateListItems()
- return s, textinput.Blink
- }
- case "x", "delete", "backspace":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- if s.deleteConfirmation == idx {
- // Second press - actually delete the session
- sessionToDelete := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- s.sessions = slices.Delete(s.sessions, idx, idx+1)
- s.deleteConfirmation = -1
- s.updateListItems()
- return nil
- },
- s.deleteSession(sessionToDelete.ID),
- )
- } else {
- // First press - enter delete confirmation mode
- s.deleteConfirmation = idx
- s.updateListItems()
- return s, nil
- }
- }
- case "esc":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- }
- }
- }
-
- if !s.renameMode {
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[sessionItem])
- return s, cmd
- }
- return s, nil
-}
-
-func (s *sessionDialog) Render(background string) string {
- if s.renameMode {
- // Show rename input instead of list
- t := theme.CurrentTheme()
- renameView := s.renameInput.View()
-
- mutedStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel()).
- Render
- helpText := mutedStyle("Enter to confirm, Esc to cancel")
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{renameView, helpText}, "\n")
- return s.modal.Render(content, background)
- }
-
- listView := s.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
- rightHelp := keyStyle("x/del") + mutedStyle(" delete")
-
- bgColor := t.BackgroundPanel()
- helpText := layout.Render(layout.FlexOptions{
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Width: layout.Current.Container.Width - 14,
- Background: &bgColor,
- }, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
-
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{listView, helpText}, "\n")
-
- return s.modal.Render(content, background)
-}
-
-func (s *sessionDialog) setupRenameInput(currentTitle string) {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundPanel()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- s.renameInput = textinput.New()
- s.renameInput.SetValue(currentTitle)
- s.renameInput.Focus()
- s.renameInput.CharLimit = 100
- s.renameInput.SetWidth(layout.Current.Container.Width - 20)
-
- s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Blurred.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
-}
-
-func (s *sessionDialog) updateListItems() {
- _, currentIdx := s.list.GetSelectedItem()
-
- var items []sessionItem
- for i, sess := range s.sessions {
- item := sessionItem{
- title: sess.Title,
- isDeleteConfirming: s.deleteConfirmation == i,
- isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
- }
- items = append(items, item)
- }
- s.list.SetItems(items)
- s.list.SetSelectedIndex(currentIdx)
-}
-
-func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
- return func() tea.Msg {
- ctx := context.Background()
- if err := s.app.DeleteSession(ctx, sessionID); err != nil {
- return toast.NewErrorToast("Failed to delete session: " + err.Error())()
- }
- return nil
- }
-}
-
-// ReopenSessionModalMsg is emitted when the session modal should be reopened
-type ReopenSessionModalMsg struct{}
-
-func (s *sessionDialog) Close() tea.Cmd {
- if s.renameMode {
- // If in rename mode, exit rename mode and return a command to reopen the modal
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
-
- // Return a command that will reopen the session modal
- return func() tea.Msg {
- return ReopenSessionModalMsg{}
- }
- }
- // Normal close behavior
- return nil
-}
-
-// NewSessionDialog creates a new session switching dialog
-func NewSessionDialog(app *app.App) SessionDialog {
- sessions, _ := app.ListSessions(context.Background())
-
- var filteredSessions []opencode.Session
- var items []sessionItem
- for _, sess := range sessions {
- if sess.ParentID != "" {
- continue
- }
- filteredSessions = append(filteredSessions, sess)
- items = append(items, sessionItem{
- title: sess.Title,
- isDeleteConfirming: false,
- isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
- })
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[sessionItem](10),
- list.WithFallbackMessage[sessionItem]("No sessions available"),
- list.WithAlphaNumericKeys[sessionItem](true),
- list.WithRenderFunc(
- func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item sessionItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &sessionDialog{
- sessions: filteredSessions,
- list: listComponent,
- app: app,
- deleteConfirmation: -1,
- renameMode: false,
- renameIndex: -1,
- modal: modal.New(
- modal.WithTitle("Switch Session"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}
diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go
deleted file mode 100644
index c71cddc8e..000000000
--- a/packages/tui/internal/components/dialog/theme.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- list "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// ThemeSelectedMsg is sent when the theme is changed
-type ThemeSelectedMsg struct {
- ThemeName string
-}
-
-// ThemeDialog interface for the theme switching dialog
-type ThemeDialog interface {
- layout.Modal
-}
-
-type themeDialog struct {
- width int
- height int
-
- modal *modal.Modal
- list list.List[list.Item]
- originalTheme string
- themeApplied bool
-}
-
-func (t *themeDialog) Init() tea.Cmd {
- return nil
-}
-
-func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- t.width = msg.Width
- t.height = msg.Height
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if item, idx := t.list.GetSelectedItem(); idx >= 0 {
- if stringItem, ok := item.(list.StringItem); ok {
- selectedTheme := string(stringItem)
- if err := theme.SetTheme(selectedTheme); err != nil {
- // status.Error(err.Error())
- return t, nil
- }
- t.themeApplied = true
- return t, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
- )
- }
- }
-
- }
- }
-
- _, prevIdx := t.list.GetSelectedItem()
-
- var cmd tea.Cmd
- listModel, cmd := t.list.Update(msg)
- t.list = listModel.(list.List[list.Item])
-
- if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
- if stringItem, ok := item.(list.StringItem); ok {
- theme.SetTheme(string(stringItem))
- return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
- }
- }
- return t, cmd
-}
-
-func (t *themeDialog) Render(background string) string {
- return t.modal.Render(t.list.View(), background)
-}
-
-func (t *themeDialog) Close() tea.Cmd {
- if !t.themeApplied {
- theme.SetTheme(t.originalTheme)
- return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
- }
- return nil
-}
-
-// NewThemeDialog creates a new theme switching dialog
-func NewThemeDialog() ThemeDialog {
- themes := theme.AvailableThemes()
- currentTheme := theme.CurrentThemeName()
-
- var selectedIdx int
- for i, name := range themes {
- if name == currentTheme {
- selectedIdx = i
- }
- }
-
- // Convert themes to list items
- items := make([]list.Item, len(themes))
- for i, theme := range themes {
- items[i] = list.StringItem(theme)
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[list.Item](10),
- list.WithFallbackMessage[list.Item]("No themes available"),
- list.WithAlphaNumericKeys[list.Item](true),
- list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- }),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- // Set the initial selection to the current theme
- listComponent.SetSelectedIndex(selectedIdx)
-
- // Set the max width for the list to match the modal width
- listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
- return &themeDialog{
- list: listComponent,
- modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
- originalTheme: currentTheme,
- themeApplied: false,
- }
-}
diff --git a/packages/tui/internal/components/dialog/timeline.go b/packages/tui/internal/components/dialog/timeline.go
deleted file mode 100644
index f2eeb7fb4..000000000
--- a/packages/tui/internal/components/dialog/timeline.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// TimelineDialog interface for the session timeline dialog
-type TimelineDialog interface {
- layout.Modal
-}
-
-// ScrollToMessageMsg is sent when a message should be scrolled to
-type ScrollToMessageMsg struct {
- MessageID string
-}
-
-// RestoreToMessageMsg is sent when conversation should be restored to a specific message
-type RestoreToMessageMsg struct {
- MessageID string
- Index int
-}
-
-// timelineItem represents a user message in the timeline list
-type timelineItem struct {
- messageID string
- content string
- timestamp time.Time
- index int // Index in the full message list
- toolCount int // Number of tools used in this message
-}
-
-func (n timelineItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
- isCurrent bool,
-) string {
- t := theme.CurrentTheme()
- infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
- textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
-
- // Add dot after timestamp if this is the current message - only apply color when not selected
- var dot string
- var dotVisualLen int
- if isCurrent {
- if selected {
- dot = "● "
- } else {
- dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
- }
- dotVisualLen = 2 // "● " is 2 characters wide
- }
-
- // Format timestamp - only apply color when not selected
- var timeStr string
- var timeVisualLen int
- if selected {
- timeStr = n.timestamp.Format("15:04") + " " + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- } else {
- timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- }
-
- // Tool count display (fixed width for alignment) - only apply color when not selected
- toolInfo := ""
- toolInfoVisualLen := 0
- if n.toolCount > 0 {
- toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
- if selected {
- toolInfo = toolInfoText
- } else {
- toolInfo = infoStyle(toolInfoText)
- }
- toolInfoVisualLen = lipgloss.Width(toolInfo)
- }
-
- // Calculate available space for content
- // Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
- reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
- contentWidth := max(width-reservedSpace, 8)
-
- truncatedContent := truncate.StringWithTail(
- strings.Split(n.content, "\n")[0],
- uint(contentWidth),
- "...",
- )
-
- // Apply normal text color to content for non-selected items
- var styledContent string
- if selected {
- styledContent = truncatedContent
- } else {
- styledContent = textStyle(truncatedContent)
- }
-
- // Create the line with proper spacing - content left-aligned, tools right-aligned
- var text string
- text = timeStr + styledContent
- if toolInfo != "" {
- bgColor := t.BackgroundPanel()
- if selected {
- bgColor = t.Primary()
- }
- text = layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: width - 2,
- },
- layout.FlexItem{
- View: text,
- },
- layout.FlexItem{
- View: toolInfo,
- },
- )
- }
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.PaddingLeft(1)
- }
-
- return itemStyle.Render(text)
-}
-
-func (n timelineItem) Selectable() bool {
- return true
-}
-
-type timelineDialog struct {
- width int
- height int
- modal *modal.Modal
- list list.List[timelineItem]
- app *app.App
-}
-
-func (n *timelineDialog) Init() tea.Cmd {
- return nil
-}
-
-func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- n.width = msg.Width
- n.height = msg.Height
- n.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- switch msg.String() {
- case "up", "down":
- // Handle navigation and immediately scroll to selected message
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
-
- // Get the newly selected item and scroll to it immediately
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- cmd,
- util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
- )
- }
- return n, cmd
- case "r":
- // Restore conversation to selected message
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
- util.CmdHandler(modal.CloseModalMsg{}),
- )
- }
- case "enter":
- // Keep Enter functionality for closing the modal
- if _, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, util.CmdHandler(modal.CloseModalMsg{})
- }
- }
- }
-
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
- return n, cmd
-}
-
-func (n *timelineDialog) Render(background string) string {
- listView := n.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- helpText := keyStyle(
- "↑/↓",
- ) + mutedStyle(
- " jump ",
- ) + keyStyle(
- "r",
- ) + mutedStyle(
- " restore",
- )
-
- bgColor := t.BackgroundPanel()
- helpView := styles.NewStyle().
- Background(bgColor).
- Width(layout.Current.Container.Width - 14).
- PaddingLeft(1).
- PaddingTop(1).
- Render(helpText)
-
- content := strings.Join([]string{listView, helpView}, "\n")
-
- return n.modal.Render(content, background)
-}
-
-func (n *timelineDialog) Close() tea.Cmd {
- return nil
-}
-
-// extractMessagePreview extracts a preview from message parts
-func extractMessagePreview(parts []opencode.PartUnion) string {
- for _, part := range parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- text := strings.TrimSpace(casted.Text)
- if text != "" {
- return text
- }
- }
- }
- return "No text content"
-}
-
-// countToolsInResponse counts tools in the assistant's response to a user message
-func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
- count := 0
- // Look at subsequent messages to find the assistant's response
- for i := userMessageIndex + 1; i < len(messages); i++ {
- message := messages[i]
- // If we hit another user message, stop looking
- if _, isUser := message.Info.(opencode.UserMessage); isUser {
- break
- }
- // Count tools in this assistant message
- for _, part := range message.Parts {
- switch part.(type) {
- case opencode.ToolPart:
- count++
- }
- }
- }
- return count
-}
-
-// NewTimelineDialog creates a new session timeline dialog
-func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
- var items []timelineItem
-
- // Filter to only user messages and extract relevant info
- for i, message := range app.Messages {
- if userMsg, ok := message.Info.(opencode.UserMessage); ok {
- preview := extractMessagePreview(message.Parts)
- toolCount := countToolsInResponse(app.Messages, i)
-
- items = append(items, timelineItem{
- messageID: userMsg.ID,
- content: preview,
- timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
- index: i,
- toolCount: toolCount,
- })
- }
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[timelineItem](12),
- list.WithFallbackMessage[timelineItem]("No user messages in this session"),
- list.WithAlphaNumericKeys[timelineItem](true),
- list.WithRenderFunc(
- func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
- // Determine if this item is the current message for the session
- isCurrent := false
- if app.Session.Revert.MessageID != "" {
- // When reverted, Session.Revert.MessageID contains the NEXT user message ID
- // So we need to find the previous user message to highlight the correct one
- for i, navItem := range items {
- if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
- // Found the next message, so the previous one is current
- isCurrent = item.messageID == items[i-1].messageID
- break
- }
- }
- } else if len(app.Messages) > 0 {
- // If not reverted, highlight the last user message
- lastUserMsgID := ""
- for i := len(app.Messages) - 1; i >= 0; i-- {
- if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
- lastUserMsgID = userMsg.ID
- break
- }
- }
- isCurrent = item.messageID == lastUserMsgID
- }
- // Only show the dot if undo/redo/restore is available
- showDot := app.Session.Revert.MessageID != ""
- return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
- },
- ),
- list.WithSelectableFunc(func(item timelineItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &timelineDialog{
- list: listComponent,
- app: app,
- modal: modal.New(
- modal.WithTitle("Session Timeline"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}