diff options
| author | Dax Raad <[email protected]> | 2025-11-02 18:43:17 -0500 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-11-02 18:43:33 -0500 |
| commit | f68374ad2223ddc213bdea9519ca6a699819ee0e (patch) | |
| tree | 04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/dialog | |
| parent | 5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff) | |
| download | opencode-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.go | 452 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/complete.go | 314 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/help.go | 80 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/models.go | 458 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/search.go | 255 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/session.go | 400 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/theme.go | 132 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/timeline.go | 353 |
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), - ), - } -} |
