diff options
Diffstat (limited to 'packages/tui/internal/components')
26 files changed, 0 insertions, 11298 deletions
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go deleted file mode 100644 index 454f1a5a9..000000000 --- a/packages/tui/internal/components/chat/cache.go +++ /dev/null @@ -1,62 +0,0 @@ -package chat - -import ( - "encoding/hex" - "fmt" - "hash/fnv" - "sync" -) - -// PartCache caches rendered messages to avoid re-rendering -type PartCache struct { - mu sync.RWMutex - cache map[string]string -} - -// NewPartCache creates a new message cache -func NewPartCache() *PartCache { - return &PartCache{ - cache: make(map[string]string), - } -} - -// generateKey creates a unique key for a message based on its content and rendering parameters -func (c *PartCache) GenerateKey(params ...any) string { - h := fnv.New64a() - for _, param := range params { - h.Write(fmt.Appendf(nil, ":%v", param)) - } - return hex.EncodeToString(h.Sum(nil)) -} - -// Get retrieves a cached rendered message -func (c *PartCache) Get(key string) (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - content, exists := c.cache[key] - return content, exists -} - -// Set stores a rendered message in the cache -func (c *PartCache) Set(key string, content string) { - c.mu.Lock() - defer c.mu.Unlock() - c.cache[key] = content -} - -// Clear removes all entries from the cache -func (c *PartCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]string) -} - -// Size returns the number of cached entries -func (c *PartCache) Size() int { - c.mu.RLock() - defer c.mu.RUnlock() - - return len(c.cache) -} diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go deleted file mode 100644 index d3c813840..000000000 --- a/packages/tui/internal/components/chat/editor.go +++ /dev/null @@ -1,906 +0,0 @@ -package chat - -import ( - "encoding/base64" - "fmt" - "log/slog" - "os" - "path/filepath" - "strconv" - "strings" - "unicode/utf8" - - "github.com/charmbracelet/bubbles/v2/spinner" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/google/uuid" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/attachment" - "github.com/sst/opencode/internal/clipboard" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/dialog" - "github.com/sst/opencode/internal/components/textarea" - "github.com/sst/opencode/internal/components/toast" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -type EditorComponent interface { - tea.Model - tea.ViewModel - Content() string - Cursor() *tea.Cursor - Lines() int - Value() string - Length() int - Focused() bool - Focus() (tea.Model, tea.Cmd) - Blur() - Submit() (tea.Model, tea.Cmd) - SubmitBash() (tea.Model, tea.Cmd) - Clear() (tea.Model, tea.Cmd) - Paste() (tea.Model, tea.Cmd) - Newline() (tea.Model, tea.Cmd) - SetValue(value string) - SetValueWithAttachments(value string) - SetInterruptKeyInDebounce(inDebounce bool) - SetExitKeyInDebounce(inDebounce bool) - RestoreFromHistory(index int) - GetAttachments() []*attachment.Attachment -} - -type editorComponent struct { - app *app.App - width int - textarea textarea.Model - spinner spinner.Model - interruptKeyInDebounce bool - exitKeyInDebounce bool - historyIndex int // -1 means current (not in history) - currentText string // Store current text when navigating history - pasteCounter int - reverted bool -} - -func (m *editorComponent) Init() tea.Cmd { - return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus) -} - -func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - 4 - return m, nil - case spinner.TickMsg: - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case tea.KeyPressMsg: - // Handle up/down arrows and ctrl+p/ctrl+n for history navigation - switch msg.String() { - case "up", "ctrl+p": - // Only navigate history if cursor is at the first line and column (for arrow keys) - // or allow ctrl+p from anywhere - if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 { - if m.historyIndex == -1 { - // Save current text before entering history - m.currentText = m.textarea.Value() - m.textarea.MoveToBegin() - } - // Move up in history (older messages) - if m.historyIndex < len(m.app.State.MessageHistory)-1 { - m.historyIndex++ - m.RestoreFromHistory(m.historyIndex) - m.textarea.MoveToBegin() - } - return m, nil - } - case "down", "ctrl+n": - // Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys) - // or allow ctrl+n from anywhere if we're in history navigation - if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 { - // Move down in history (newer messages) - m.historyIndex-- - if m.historyIndex == -1 { - // Restore current text - m.textarea.Reset() - m.textarea.SetValue(m.currentText) - m.currentText = "" - } else { - m.RestoreFromHistory(m.historyIndex) - m.textarea.MoveToEnd() - } - return m, nil - } else if m.historyIndex > -1 && msg.String() == "down" { - m.textarea.MoveToEnd() - return m, nil - } - } - // Reset history navigation on any other input - if m.historyIndex != -1 { - m.historyIndex = -1 - m.currentText = "" - } - // Maximize editor responsiveness for printable characters - if msg.Text != "" { - m.reverted = false - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - case app.MessageRevertedMsg: - if msg.Session.ID == m.app.Session.ID { - switch msg.Message.Info.(type) { - case opencode.UserMessage: - prompt, err := msg.Message.ToPrompt() - if err != nil { - return m, toast.NewErrorToast("Failed to revert message") - } - m.RestoreFromPrompt(*prompt) - m.textarea.MoveToEnd() - m.reverted = true - return m, nil - } - } - case app.SessionUnrevertedMsg: - if msg.Session.ID == m.app.Session.ID { - if m.reverted { - updated, cmd := m.Clear() - m = updated.(*editorComponent) - return m, cmd - } - return m, nil - } - case tea.PasteMsg: - text := string(msg) - - if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" { - statPath := filePath - if !filepath.IsAbs(filePath) { - statPath = filepath.Join(util.CwdPath, filePath) - } - if _, err := os.Stat(statPath); err == nil { - attachment := m.createAttachmentFromPath(filePath) - if attachment != nil { - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - } - } - } - - text = strings.ReplaceAll(text, "\\", "") - text, err := strconv.Unquote(`"` + text + `"`) - if err != nil { - slog.Error("Failed to unquote text", "error", err) - text := string(msg) - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - if _, err := os.Stat(text); err != nil { - slog.Error("Failed to paste file", "error", err) - text := string(msg) - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - - filePath := text - - attachment := m.createAttachmentFromFile(filePath) - if attachment == nil { - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - case tea.ClipboardMsg: - text := string(msg) - // Check if the pasted text is long and should be summarized - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(text)) - } - case dialog.ThemeSelectedMsg: - m.textarea = updateTextareaStyles(m.textarea) - m.spinner = createSpinner() - return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick) - case dialog.CompletionSelectedMsg: - switch msg.Item.ProviderID { - case "commands": - command := msg.Item.RawData.(commands.Command) - if command.Custom { - m.SetValue("/" + command.PrimaryTrigger() + " ") - return m, nil - } - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - commandName := strings.TrimPrefix(msg.Item.Value, "/") - cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) - return m, tea.Batch(cmds...) - case "files": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - // The range to replace is from the '@' up to the current cursor position. - // Replace the search term (e.g., "@search") with an empty string first. - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - // Now, insert the attachment at the position where the '@' was. - // The cursor is now at `atIndex` after the replacement. - filePath := msg.Item.Value - attachment := m.createAttachmentFromPath(filePath) - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - case "symbols": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - symbol := msg.Item.RawData.(opencode.Symbol) - parts := strings.Split(symbol.Name, ".") - lastPart := parts[len(parts)-1] - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "symbol", - Display: "@" + lastPart, - URL: msg.Item.Value, - Filename: lastPart, - MediaType: "text/plain", - Source: &attachment.SymbolSource{ - Path: symbol.Location.Uri, - Name: symbol.Name, - Kind: int(symbol.Kind), - Range: attachment.SymbolRange{ - Start: attachment.Position{ - Line: int(symbol.Location.Range.Start.Line), - Char: int(symbol.Location.Range.Start.Character), - }, - End: attachment.Position{ - Line: int(symbol.Location.Range.End.Line), - Char: int(symbol.Location.Range.End.Character), - }, - }, - }, - } - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - case "agents": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - name := msg.Item.Value - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "agent", - Display: "@" + name, - Source: &attachment.AgentSource{ - Name: name, - }, - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - - default: - slog.Debug("Unknown provider", "provider", msg.Item.ProviderID) - return m, nil - } - } - - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) Content() string { - width := m.width - if m.app.Session.ID == "" { - width = min(width, 80) - } - - t := theme.CurrentTheme() - base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render - muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render - - promptStyle := styles.NewStyle().Foreground(t.Primary()). - Padding(0, 0, 0, 1). - Bold(true) - prompt := promptStyle.Render(">") - borderForeground := t.Border() - if m.app.IsLeaderSequence { - borderForeground = t.Accent() - } - if m.app.IsBashMode { - borderForeground = t.Secondary() - prompt = promptStyle.Render("!") - } - - m.textarea.SetWidth(width - 6) - textarea := lipgloss.JoinHorizontal( - lipgloss.Top, - prompt, - m.textarea.View(), - ) - textarea = styles.NewStyle(). - Background(t.BackgroundElement()). - Width(width). - PaddingTop(1). - PaddingBottom(1). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(borderForeground). - BorderBackground(t.Background()). - BorderLeft(true). - BorderRight(true). - Render(textarea) - - hint := base(m.getSubmitKeyText()) + muted(" send ") - if m.exitKeyInDebounce { - keyText := m.getExitKeyText() - hint = base(keyText+" again") + muted(" to exit") - } else if m.app.IsBusy() { - keyText := m.getInterruptKeyText() - status := "working" - if m.app.IsCompacting() { - status = "compacting" - } - if m.app.CurrentPermission.ID != "" { - status = "waiting for permission" - } - if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" { - hint = muted( - status, - ) + m.spinner.View() + muted( - " ", - ) + base( - keyText+" again", - ) + muted( - " interrupt", - ) - } else { - hint = muted(status) + m.spinner.View() - if m.app.CurrentPermission.ID == "" { - hint += muted(" ") + base(keyText) + muted(" interrupt") - } - } - } - - model := "" - if m.app.Model != nil { - model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) - } - - space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) - spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") - - info := hint + spacer + model - info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info) - - content := strings.Join([]string{"", textarea, info}, "\n") - return content -} - -func (m *editorComponent) Cursor() *tea.Cursor { - return m.textarea.Cursor() -} - -func (m *editorComponent) View() string { - width := m.width - if m.app.Session.ID == "" { - width = min(width, 80) - } - - if m.Lines() > 1 { - return lipgloss.Place( - width, - 5, - lipgloss.Center, - lipgloss.Center, - "", - styles.WhitespaceStyle(theme.CurrentTheme().Background()), - ) - } - return m.Content() -} - -func (m *editorComponent) Focused() bool { - return m.textarea.Focused() -} - -func (m *editorComponent) Focus() (tea.Model, tea.Cmd) { - return m, m.textarea.Focus() -} - -func (m *editorComponent) Blur() { - m.textarea.Blur() -} - -func (m *editorComponent) Lines() int { - return m.textarea.LineCount() -} - -func (m *editorComponent) Value() string { - return m.textarea.Value() -} - -func (m *editorComponent) Length() int { - return m.textarea.Length() -} - -func (m *editorComponent) GetAttachments() []*attachment.Attachment { - return m.textarea.GetAttachments() -} - -func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { - value := strings.TrimSpace(m.Value()) - if value == "" { - return m, nil - } - - switch value { - case "exit", "quit", "q", ":q": - return m, tea.Quit - } - - if len(value) > 0 && value[len(value)-1] == '\\' { - // If the last character is a backslash, remove it and add a newline - backslashCol := m.textarea.CurrentRowLength() - 1 - m.textarea.ReplaceRange(backslashCol, backslashCol+1, "") - m.textarea.InsertString("\n") - return m, nil - } - - var cmds []tea.Cmd - if strings.HasPrefix(value, "/") { - // Expand attachments in the value to get actual content - expandedValue := value - attachments := m.textarea.GetAttachments() - for _, att := range attachments { - if att.Type == "text" && att.Source != nil { - if textSource, ok := att.Source.(*attachment.TextSource); ok { - expandedValue = strings.Replace(expandedValue, att.Display, textSource.Value, 1) - } - } - } - - expandedValue = expandedValue[1:] // Remove the "/" - commandName := strings.Split(expandedValue, " ")[0] - command := m.app.Commands[commands.CommandName(commandName)] - if command.Custom { - args := "" - if strings.HasPrefix(expandedValue, command.PrimaryTrigger()+" ") { - args = strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ") - } - cmds = append( - cmds, - util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}), - ) - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) - } - } - - attachments := m.textarea.GetAttachments() - - prompt := app.Prompt{Text: value, Attachments: attachments} - m.app.State.AddPromptToHistory(prompt) - cmds = append(cmds, m.app.SaveState()) - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt))) - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) { - command := m.textarea.Value() - var cmds []tea.Cmd - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command})) - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { - m.textarea.Reset() - m.historyIndex = -1 - m.currentText = "" - m.pasteCounter = 0 - return m, nil -} - -func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - imageBytes := clipboard.Read(clipboard.FmtImage) - if imageBytes != nil { - attachmentCount := len(m.textarea.GetAttachments()) - attachmentIndex := attachmentCount + 1 - base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes) - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - MediaType: "image/png", - Display: fmt.Sprintf("[Image #%d]", attachmentIndex), - Filename: fmt.Sprintf("image-%d.png", attachmentIndex), - URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile), - Source: &attachment.FileSource{ - Path: fmt.Sprintf("image-%d.png", attachmentIndex), - Mime: "image/png", - Data: imageBytes, - }, - } - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - } - - textBytes := clipboard.Read(clipboard.FmtText) - if textBytes != nil { - text := string(textBytes) - // Check if the pasted text is long and should be summarized - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(text)) - } - return m, nil - } - - // fallback to reading the clipboard using OSC52 - return m, tea.ReadClipboard -} - -func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { - m.textarea.Newline() - return m, nil -} - -func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { - m.interruptKeyInDebounce = inDebounce -} - -func (m *editorComponent) SetValue(value string) { - m.textarea.SetValue(value) -} - -func (m *editorComponent) SetValueWithAttachments(value string) { - m.textarea.Reset() - - i := 0 - for i < len(value) { - r, size := utf8.DecodeRuneInString(value[i:]) - // Check if filepath and add attachment - if r == '@' { - start := i + size - end := start - for end < len(value) { - nextR, nextSize := utf8.DecodeRuneInString(value[end:]) - if nextR == ' ' || nextR == '\t' || nextR == '\n' || nextR == '\r' { - break - } - end += nextSize - } - if end > start { - filePath := value[start:end] - if _, err := os.Stat(filepath.Join(util.CwdPath, filePath)); err == nil { - attachment := m.createAttachmentFromFile(filePath) - if attachment != nil { - m.textarea.InsertAttachment(attachment) - i = end - continue - } - } - } - } - - // Not a valid file path, insert the character normally - m.textarea.InsertRune(r) - i += size - } -} - -func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) { - m.exitKeyInDebounce = inDebounce -} - -func (m *editorComponent) getInterruptKeyText() string { - return m.app.Commands[commands.SessionInterruptCommand].Keys()[0] -} - -func (m *editorComponent) getSubmitKeyText() string { - return m.app.Commands[commands.InputSubmitCommand].Keys()[0] -} - -func (m *editorComponent) getExitKeyText() string { - return m.app.Commands[commands.AppExitCommand].Keys()[0] -} - -// shouldSummarizePastedText determines if pasted text should be summarized -func (m *editorComponent) shouldSummarizePastedText(text string) bool { - if m.app.IsBashMode { - return false - } - - if m.app.Config != nil && m.app.Config.Experimental.DisablePasteSummary { - return false - } - - lines := strings.Split(text, "\n") - lineCount := len(lines) - charCount := len(text) - - // Consider text long if it has more than 3 lines or more than 150 characters - return lineCount > 3 || charCount > 150 -} - -// handleLongPaste handles long pasted text by creating a summary attachment -func (m *editorComponent) handleLongPaste(text string) { - lines := strings.Split(text, "\n") - lineCount := len(lines) - - // Increment paste counter - m.pasteCounter++ - - // Create attachment with full text as base64 encoded data - fileBytes := []byte(text) - base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes) - url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText) - - fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter) - displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount) - - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "text", - MediaType: "text/plain", - Display: displayText, - URL: url, - Filename: fileName, - Source: &attachment.TextSource{ - Value: text, - }, - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") -} - -func updateTextareaStyles(ta textarea.Model) textarea.Model { - t := theme.CurrentTheme() - bgColor := t.BackgroundElement() - textColor := t.Text() - textMutedColor := t.TextMuted() - - ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Blurred.Placeholder = styles.NewStyle(). - Foreground(textMutedColor). - Background(bgColor). - Lipgloss() - ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Focused.Placeholder = styles.NewStyle(). - Foreground(textMutedColor). - Background(bgColor). - Lipgloss() - ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Attachment = styles.NewStyle(). - Foreground(t.Secondary()). - Background(bgColor). - Lipgloss() - ta.Styles.SelectedAttachment = styles.NewStyle(). - Foreground(t.Text()). - Background(t.Secondary()). - Lipgloss() - ta.Styles.Cursor.Color = t.Primary() - return ta -} - -func createSpinner() spinner.Model { - t := theme.CurrentTheme() - return spinner.New( - spinner.WithSpinner(spinner.Ellipsis), - spinner.WithStyle( - styles.NewStyle(). - Background(t.Background()). - Foreground(t.TextMuted()). - Width(3). - Lipgloss(), - ), - ) -} - -func NewEditorComponent(app *app.App) EditorComponent { - s := createSpinner() - - ta := textarea.New() - ta.Prompt = " " - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.VirtualCursor = false - ta = updateTextareaStyles(ta) - - m := &editorComponent{ - app: app, - textarea: ta, - spinner: s, - interruptKeyInDebounce: false, - historyIndex: -1, - pasteCounter: 0, - } - - return m -} - -func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) { - m.textarea.Reset() - m.textarea.SetValue(prompt.Text) - - // Sort attachments by start index in reverse order (process from end to beginning) - // This prevents index shifting issues - attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments)) - copy(attachmentsCopy, prompt.Attachments) - - for i := 0; i < len(attachmentsCopy)-1; i++ { - for j := i + 1; j < len(attachmentsCopy); j++ { - if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex { - attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i] - } - } - } - - for _, att := range attachmentsCopy { - m.textarea.SetCursorColumn(att.StartIndex) - m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "") - m.textarea.InsertAttachment(att) - } -} - -// RestoreFromHistory restores a message from history at the given index -func (m *editorComponent) RestoreFromHistory(index int) { - if index < 0 || index >= len(m.app.State.MessageHistory) { - return - } - entry := m.app.State.MessageHistory[index] - m.RestoreFromPrompt(entry) -} - -func getMediaTypeFromExtension(ext string) string { - switch strings.ToLower(ext) { - case ".jpg": - return "image/jpeg" - case ".png", ".jpeg", ".gif", ".webp": - return "image/" + ext[1:] - case ".pdf": - return "application/pdf" - default: - return "text/plain" - } -} - -func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment { - ext := strings.ToLower(filepath.Ext(filePath)) - mediaType := getMediaTypeFromExtension(ext) - absolutePath := filePath - if !filepath.IsAbs(filePath) { - absolutePath = filepath.Join(util.CwdPath, filePath) - } - - // For text files, create a simple file reference - if mediaType == "text/plain" { - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - Display: "@" + filePath, - URL: fmt.Sprintf("file://%s", absolutePath), - Filename: filePath, - MediaType: mediaType, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - }, - } - } - - // For binary files (images, PDFs), read and encode - fileBytes, err := os.ReadFile(filePath) - if err != nil { - slog.Error("Failed to read file", "error", err) - return nil - } - - base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes) - url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile) - attachmentCount := len(m.textarea.GetAttachments()) - attachmentIndex := attachmentCount + 1 - label := "File" - if strings.HasPrefix(mediaType, "image/") { - label = "Image" - } - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - MediaType: mediaType, - Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex), - URL: url, - Filename: filePath, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - Data: fileBytes, - }, - } -} - -func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment { - extension := filepath.Ext(filePath) - mediaType := getMediaTypeFromExtension(extension) - absolutePath := filePath - if !filepath.IsAbs(filePath) { - absolutePath = filepath.Join(util.CwdPath, filePath) - } - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - Display: "@" + filePath, - URL: fmt.Sprintf("file://%s", absolutePath), - Filename: filePath, - MediaType: mediaType, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - }, - } -} diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go deleted file mode 100644 index 801545a88..000000000 --- a/packages/tui/internal/components/chat/message.go +++ /dev/null @@ -1,1031 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - "maps" - "slices" - "strings" - "time" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" - "github.com/muesli/reflow/truncate" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/diff" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -type blockRenderer struct { - textColor compat.AdaptiveColor - backgroundColor compat.AdaptiveColor - border bool - borderColor *compat.AdaptiveColor - borderLeft bool - borderRight bool - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int -} - -type renderingOption func(*blockRenderer) - -func WithTextColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.textColor = color - } -} - -func WithBackgroundColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.backgroundColor = color - } -} - -func WithNoBorder() renderingOption { - return func(c *blockRenderer) { - c.border = false - c.paddingLeft++ - c.paddingRight++ - } -} - -func WithBorderColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.borderColor = &color - } -} - -func WithBorderLeft() renderingOption { - return func(c *blockRenderer) { - c.borderLeft = true - c.borderRight = false - } -} - -func WithBorderRight() renderingOption { - return func(c *blockRenderer) { - c.borderLeft = false - c.borderRight = true - } -} - -func WithBorderBoth(value bool) renderingOption { - return func(c *blockRenderer) { - if value { - c.borderLeft = true - c.borderRight = true - } - } -} - -func WithMarginTop(padding int) renderingOption { - return func(c *blockRenderer) { - c.marginTop = padding - } -} - -func WithMarginBottom(padding int) renderingOption { - return func(c *blockRenderer) { - c.marginBottom = padding - } -} - -func WithPadding(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingTop = padding - c.paddingBottom = padding - c.paddingLeft = padding - c.paddingRight = padding - } -} - -func WithPaddingLeft(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingLeft = padding - } -} - -func WithPaddingRight(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingRight = padding - } -} - -func WithPaddingTop(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingTop = padding - } -} - -func WithPaddingBottom(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingBottom = padding - } -} - -func renderContentBlock( - app *app.App, - content string, - width int, - options ...renderingOption, -) string { - t := theme.CurrentTheme() - renderer := &blockRenderer{ - textColor: t.TextMuted(), - backgroundColor: t.BackgroundPanel(), - border: true, - borderLeft: true, - borderRight: false, - paddingTop: 1, - paddingBottom: 1, - paddingLeft: 2, - paddingRight: 2, - } - for _, option := range options { - option(renderer) - } - - borderColor := t.BackgroundPanel() - if renderer.borderColor != nil { - borderColor = *renderer.borderColor - } - - style := styles.NewStyle(). - Foreground(renderer.textColor). - Background(renderer.backgroundColor). - PaddingTop(renderer.paddingTop). - PaddingBottom(renderer.paddingBottom). - PaddingLeft(renderer.paddingLeft). - PaddingRight(renderer.paddingRight). - AlignHorizontal(lipgloss.Left) - - if renderer.border { - style = style. - BorderStyle(lipgloss.ThickBorder()). - BorderLeft(true). - BorderRight(true). - BorderLeftForeground(t.BackgroundPanel()). - BorderLeftBackground(t.Background()). - BorderRightForeground(t.BackgroundPanel()). - BorderRightBackground(t.Background()) - - if renderer.borderLeft { - style = style.BorderLeftForeground(borderColor) - } - if renderer.borderRight { - style = style.BorderRightForeground(borderColor) - } - } else { - style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight) - } - - content = style.Render(content) - if renderer.marginTop > 0 { - for range renderer.marginTop { - content = "\n" + content - } - } - if renderer.marginBottom > 0 { - for range renderer.marginBottom { - content = content + "\n" - } - } - - return content -} - -func renderText( - app *app.App, - message opencode.MessageUnion, - text string, - author string, - showToolDetails bool, - width int, - extra string, - isThinking bool, - isQueued bool, - shimmer bool, - fileParts []opencode.FilePart, - agentParts []opencode.AgentPart, - toolCalls ...opencode.ToolPart, -) string { - t := theme.CurrentTheme() - - var ts time.Time - backgroundColor := t.BackgroundPanel() - var content string - switch casted := message.(type) { - case opencode.AssistantMessage: - backgroundColor = t.Background() - if isThinking { - backgroundColor = t.BackgroundPanel() - } - ts = time.UnixMilli(int64(casted.Time.Created)) - if casted.Time.Completed > 0 { - ts = time.UnixMilli(int64(casted.Time.Completed)) - } - content = util.ToMarkdown(text, width, backgroundColor) - if isThinking { - var label string - if shimmer { - label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent()) - } else { - label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...") - } - label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) - content = label + "\n\n" + content - } else if strings.TrimSpace(text) == "Generating..." { - label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text()) - label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) - content = label - } - case opencode.UserMessage: - ts = time.UnixMilli(int64(casted.Time.Created)) - base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor) - - var result strings.Builder - lastEnd := int64(0) - - // Apply highlighting to filenames and base style to rest of text BEFORE wrapping - textLen := int64(len(text)) - - // Collect all parts to highlight (both file and agent parts) - type highlightPart struct { - start int64 - end int64 - color compat.AdaptiveColor - } - var highlights []highlightPart - - // Add file parts with secondary color - for _, filePart := range fileParts { - highlights = append(highlights, highlightPart{ - start: filePart.Source.Text.Start, - end: filePart.Source.Text.End, - color: t.Secondary(), - }) - } - - // Add agent parts with secondary color (same as file parts) - for _, agentPart := range agentParts { - highlights = append(highlights, highlightPart{ - start: agentPart.Source.Start, - end: agentPart.Source.End, - color: t.Secondary(), - }) - } - - // Sort highlights by start position - slices.SortFunc(highlights, func(a, b highlightPart) int { - if a.start < b.start { - return -1 - } - if a.start > b.start { - return 1 - } - return 0 - }) - - // Merge overlapping highlights to prevent duplication - merged := make([]highlightPart, 0) - for _, part := range highlights { - if len(merged) == 0 { - merged = append(merged, part) - continue - } - - last := &merged[len(merged)-1] - // If current part overlaps with the last one, merge them - if part.start <= last.end { - if part.end > last.end { - last.end = part.end - } - } else { - merged = append(merged, part) - } - } - - for _, part := range merged { - highlight := base.Foreground(part.color) - start, end := part.start, part.end - - if end > textLen { - end = textLen - } - if start > textLen { - start = textLen - } - - if start > lastEnd { - result.WriteString(base.Render(text[lastEnd:start])) - } - if start < end { - result.WriteString(highlight.Render(text[start:end])) - } - - lastEnd = end - } - - if lastEnd < textLen { - result.WriteString(base.Render(text[lastEnd:])) - } - - // wrap styled text - styledText := result.String() - styledText = strings.ReplaceAll(styledText, "-", "\u2011") - wrappedText := ansi.WordwrapWc(styledText, width-6, " ") - wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-") - content = base.Width(width - 6).Render(wrappedText) - if isQueued { - queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1) - content = queuedStyle.Render("QUEUED") + "\n\n" + content - } - } - - timestamp := ts. - Local(). - Format("02 Jan 2006 03:04 PM") - if time.Now().Format("02 Jan 2006") == timestamp[:11] { - timestamp = timestamp[12:] - } - timestamp = styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Render(" (" + timestamp + ")") - - // Check if this is an assistant message with agent information - var modelAndAgentSuffix string - if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" { - // Find the agent index by name to get the correct color - var agentIndex int - for i, agent := range app.Agents { - if agent.Name == assistantMsg.Mode { - agentIndex = i - break - } - } - - // Get agent color based on the original agent index (same as status bar) - agentColor := util.GetAgentColor(agentIndex) - - // Style the agent name with the same color as status bar - agentName := cases.Title(language.Und).String(assistantMsg.Mode) - styledAgentName := styles.NewStyle(). - Background(backgroundColor). - Foreground(agentColor). - Render(agentName + " ") - styledModelID := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Render(assistantMsg.ModelID) - modelAndAgentSuffix = styledAgentName + styledModelID - } - - var info string - if modelAndAgentSuffix != "" { - info = modelAndAgentSuffix + timestamp - } else { - info = author + timestamp - } - if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { - for _, toolCall := range toolCalls { - title := renderToolTitle(toolCall, width-2) - style := styles.NewStyle() - if toolCall.State.Status == opencode.ToolPartStateStatusError { - style = style.Foreground(t.Error()) - } - title = style.Render(title) - title = "\n∟ " + title - content = content + title - } - } - - sections := []string{content} - if extra != "" { - sections = append(sections, "\n"+extra+"\n") - } - sections = append(sections, info) - content = strings.Join(sections, "\n") - - switch message.(type) { - case opencode.UserMessage: - borderColor := t.Secondary() - if isQueued { - borderColor = t.Accent() - } - return renderContentBlock( - app, - content, - width, - WithTextColor(t.Text()), - WithBorderColor(borderColor), - ) - case opencode.AssistantMessage: - if isThinking { - return renderContentBlock( - app, - content, - width, - WithTextColor(t.Text()), - WithBackgroundColor(t.BackgroundPanel()), - WithBorderColor(t.BackgroundPanel()), - ) - } - return renderContentBlock( - app, - content, - width, - WithNoBorder(), - WithBackgroundColor(t.Background()), - ) - } - return "" -} - -func renderToolDetails( - app *app.App, - toolCall opencode.ToolPart, - permission opencode.Permission, - width int, -) string { - measure := util.Measure("chat.renderToolDetails") - defer measure("tool", toolCall.Tool) - ignoredTools := []string{"todoread"} - if slices.Contains(ignoredTools, toolCall.Tool) { - return "" - } - - if toolCall.State.Status == opencode.ToolPartStateStatusPending { - title := renderToolTitle(toolCall, width) - return renderContentBlock(app, title, width) - } - - var result *string - if toolCall.State.Output != "" { - result = &toolCall.State.Output - } - - toolInputMap := make(map[string]any) - if toolCall.State.Input != nil { - value := toolCall.State.Input - if m, ok := value.(map[string]any); ok { - toolInputMap = m - keys := make([]string, 0, len(toolInputMap)) - for key := range toolInputMap { - keys = append(keys, key) - } - slices.Sort(keys) - } - } - - body := "" - t := theme.CurrentTheme() - backgroundColor := t.BackgroundPanel() - borderColor := t.BackgroundPanel() - defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render - baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render - mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render - - permissionContent := "" - if permission.ID != "" { - borderColor = t.Warning() - - base := styles.NewStyle().Background(backgroundColor) - text := base.Foreground(t.Text()).Bold(true).Render - muted := base.Foreground(t.TextMuted()).Render - if permission.Type == "doom-loop" { - permissionContent = permission.Title + "\n\n" - } else { - permissionContent = "Permission required to run this tool:\n\n" - } - permissionContent += text( - "enter ", - ) + muted( - "accept ", - ) + text( - "a", - ) + muted( - " accept always ", - ) + text( - "esc", - ) + muted( - " reject", - ) - - } - - if permission.Metadata != nil { - metadata, ok := toolCall.State.Metadata.(map[string]any) - if metadata == nil || !ok { - metadata = map[string]any{} - } - maps.Copy(metadata, permission.Metadata) - toolCall.State.Metadata = metadata - } - - if toolCall.State.Metadata != nil { - metadata := toolCall.State.Metadata.(map[string]any) - switch toolCall.Tool { - case "read": - var preview any - if metadata != nil { - preview = metadata["preview"] - } - if preview != nil && toolInputMap["filePath"] != nil { - filename := toolInputMap["filePath"].(string) - body = preview.(string) - body = util.RenderFile(filename, body, width, util.WithTruncate(6)) - } - case "edit": - if filename, ok := toolInputMap["filePath"].(string); ok { - var diffField any - if metadata != nil { - diffField = metadata["diff"] - } - if diffField != nil { - patch := diffField.(string) - var formattedDiff string - if width < 120 { - formattedDiff, _ = diff.FormatUnifiedDiff( - filename, - patch, - diff.WithWidth(width-2), - ) - } else { - formattedDiff, _ = diff.FormatDiff( - filename, - patch, - diff.WithWidth(width-2), - ) - } - body = strings.TrimSpace(formattedDiff) - style := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Padding(1, 2). - Width(width - 4) - - if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" { - diagnostics = style.Render(diagnostics) - body += "\n" + diagnostics - } - - title := renderToolTitle(toolCall, width) - title = style.Render(title) - content := title + "\n" + body - - if toolCall.State.Status == opencode.ToolPartStateStatusError { - errorStyle := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.Error()). - Padding(1, 2). - Width(width - 4) - errorContent := errorStyle.Render(toolCall.State.Error) - content += "\n" + errorContent - } - - if permissionContent != "" { - permissionContent = styles.NewStyle(). - Background(backgroundColor). - Padding(1, 2). - Render(permissionContent) - content += "\n" + permissionContent - } - content = renderContentBlock( - app, - content, - width, - WithPadding(0), - WithBorderColor(borderColor), - WithBorderBoth(permission.ID != ""), - ) - return content - } - } - case "write": - if filename, ok := toolInputMap["filePath"].(string); ok { - if content, ok := toolInputMap["content"].(string); ok { - body = util.RenderFile(filename, content, width) - if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" { - body += "\n\n" + diagnostics - } - } - } - case "bash": - if command, ok := toolInputMap["command"].(string); ok { - body = fmt.Sprintf("```console\n$ %s\n", command) - output := metadata["output"] - if output != nil { - body += ansi.Strip(fmt.Sprintf("%s", output)) - } - body += "```" - body = util.ToMarkdown(body, width, backgroundColor) - } - case "webfetch": - if format, ok := toolInputMap["format"].(string); ok && result != nil { - body = *result - body = util.TruncateHeight(body, 10) - if format == "html" || format == "markdown" { - body = util.ToMarkdown(body, width, backgroundColor) - } - } - case "todowrite": - todos := metadata["todos"] - if todos != nil { - for _, item := range todos.([]any) { - todo := item.(map[string]any) - content := todo["content"] - if content == nil { - continue - } - switch todo["status"] { - case "completed": - body += fmt.Sprintf("- [x] %s\n", content) - case "cancelled": - // strike through cancelled todo - body += fmt.Sprintf("- [ ] ~~%s~~\n", content) - case "in_progress": - // highlight in progress todo - body += fmt.Sprintf("- [ ] `%s`\n", content) - default: - body += fmt.Sprintf("- [ ] %s\n", content) - } - } - body = util.ToMarkdown(body, width, backgroundColor) - } - case "task": - summary := metadata["summary"] - if summary != nil { - toolcalls := summary.([]any) - steps := []string{} - for _, item := range toolcalls { - data, _ := json.Marshal(item) - var toolCall opencode.ToolPart - _ = json.Unmarshal(data, &toolCall) - step := renderToolTitle(toolCall, width-2) - step = "∟ " + step - steps = append(steps, step) - } - body = strings.Join(steps, "\n") - - body += "\n\n" - - // Build navigation hint with proper spacing - cycleKeybind := app.Keybind(commands.SessionChildCycleCommand) - cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand) - - var navParts []string - if cycleKeybind != "" { - navParts = append(navParts, baseStyle(cycleKeybind)) - } - if cycleReverseKeybind != "" { - navParts = append(navParts, baseStyle(cycleReverseKeybind)) - } - - if len(navParts) > 0 { - body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions") - } - } - body = defaultStyle(body) - default: - if result == nil { - empty := "" - result = &empty - } - body = *result - body = util.TruncateHeight(body, 10) - body = defaultStyle(body) - } - } - - error := "" - if toolCall.State.Status == opencode.ToolPartStateStatusError { - error = toolCall.State.Error - } - - if error != "" { - errorContent := styles.NewStyle(). - Width(width - 6). - Foreground(t.Error()). - Background(backgroundColor). - Render(error) - - if body == "" { - body = errorContent - } else { - body += "\n\n" + errorContent - } - } - - if body == "" && error == "" && result != nil { - body = *result - body = util.TruncateHeight(body, 10) - body = defaultStyle(body) - } - - if body == "" { - body = defaultStyle("") - } - - title := renderToolTitle(toolCall, width) - content := title + "\n\n" + body - - if permissionContent != "" { - content += "\n\n\n" + permissionContent - } - - return renderContentBlock( - app, - content, - width, - WithBorderColor(borderColor), - WithBorderBoth(permission.ID != ""), - ) -} - -func renderToolName(name string) string { - switch name { - case "bash": - return "Shell" - case "webfetch": - return "Fetch" - case "invalid": - return "Invalid" - default: - normalizedName := name - if after, ok := strings.CutPrefix(name, "opencode_"); ok { - normalizedName = after - } - return cases.Title(language.Und).String(normalizedName) - } -} - -func getTodoPhase(metadata map[string]any) string { - todos, ok := metadata["todos"].([]any) - if !ok || len(todos) == 0 { - return "Plan" - } - - counts := map[string]int{"pending": 0, "completed": 0} - for _, item := range todos { - if todo, ok := item.(map[string]any); ok { - if status, ok := todo["status"].(string); ok { - counts[status]++ - } - } - } - - total := len(todos) - switch { - case counts["pending"] == total: - return "Creating plan" - case counts["completed"] == total: - return "Completing plan" - default: - return "Updating plan" - } -} - -func getTodoTitle(toolCall opencode.ToolPart) string { - if toolCall.State.Status == opencode.ToolPartStateStatusCompleted { - if metadata, ok := toolCall.State.Metadata.(map[string]any); ok { - return getTodoPhase(metadata) - } - } - return "Plan" -} - -func renderToolTitle( - toolCall opencode.ToolPart, - width int, -) string { - if toolCall.State.Status == opencode.ToolPartStateStatusPending { - title := renderToolAction(toolCall.Tool) - t := theme.CurrentTheme() - shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent()) - return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny) - } - - toolArgs := "" - toolArgsMap := make(map[string]any) - if toolCall.State.Input != nil { - value := toolCall.State.Input - if m, ok := value.(map[string]any); ok { - toolArgsMap = m - - keys := make([]string, 0, len(toolArgsMap)) - for key := range toolArgsMap { - keys = append(keys, key) - } - slices.Sort(keys) - firstKey := "" - if len(keys) > 0 { - firstKey = keys[0] - } - - toolArgs = renderArgs(&toolArgsMap, firstKey) - } - } - - title := renderToolName(toolCall.Tool) - switch toolCall.Tool { - case "read": - toolArgs = renderArgs(&toolArgsMap, "filePath") - title = fmt.Sprintf("%s %s", title, toolArgs) - case "edit", "write": - if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, util.Relative(filename)) - } - case "bash": - if description, ok := toolArgsMap["description"].(string); ok { - title = fmt.Sprintf("%s %s", title, description) - } - case "task": - description := toolArgsMap["description"] - subagent := toolArgsMap["subagent_type"] - if description != nil && subagent != nil { - title = fmt.Sprintf("%s[%s] %s", title, subagent, description) - } else if description != nil { - title = fmt.Sprintf("%s %s", title, description) - } - case "webfetch": - toolArgs = renderArgs(&toolArgsMap, "url") - title = fmt.Sprintf("%s %s", title, toolArgs) - case "todowrite": - title = getTodoTitle(toolCall) - case "todoread": - return "Plan" - case "invalid": - if actualTool, ok := toolArgsMap["tool"].(string); ok { - title = renderToolName(actualTool) - } - default: - toolName := renderToolName(toolCall.Tool) - title = fmt.Sprintf("%s %s", toolName, toolArgs) - } - - title = truncate.StringWithTail(title, uint(width-6), "...") - if toolCall.State.Error != "" { - t := theme.CurrentTheme() - title = styles.NewStyle().Foreground(t.Error()).Render(title) - } - return title -} - -func renderToolAction(name string) string { - switch name { - case "task": - return "Delegating..." - case "bash": - return "Writing command..." - case "edit": - return "Preparing edit..." - case "webfetch": - return "Fetching from the web..." - case "glob": - return "Finding files..." - case "grep": - return "Searching content..." - case "list": - return "Listing directory..." - case "read": - return "Reading file..." - case "write": - return "Preparing write..." - case "todowrite", "todoread": - return "Planning..." - case "patch": - return "Preparing patch..." - } - return "Working..." -} - -func renderArgs(args *map[string]any, titleKey string) string { - if args == nil || len(*args) == 0 { - return "" - } - - keys := make([]string, 0, len(*args)) - for key := range *args { - keys = append(keys, key) - } - slices.Sort(keys) - - title := "" - parts := []string{} - for _, key := range keys { - value := (*args)[key] - if value == nil { - continue - } - if key == "filePath" || key == "path" { - if strValue, ok := value.(string); ok { - value = util.Relative(strValue) - } - } - if key == titleKey { - title = fmt.Sprintf("%s", value) - continue - } - parts = append(parts, fmt.Sprintf("%s=%v", key, value)) - } - if len(parts) == 0 { - return title - } - return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) -} - -// Diagnostic represents an LSP diagnostic -type Diagnostic struct { - Range struct { - Start struct { - Line int `json:"line"` - Character int `json:"character"` - } `json:"start"` - } `json:"range"` - Severity int `json:"severity"` - Message string `json:"message"` -} - -// renderDiagnostics formats LSP diagnostics for display in the TUI -func renderDiagnostics( - metadata map[string]any, - filePath string, - backgroundColor compat.AdaptiveColor, - width int, -) string { - if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok { - if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok { - var errorDiagnostics []string - for _, diagInterface := range fileDiagnostics { - diagMap, ok := diagInterface.(map[string]any) - if !ok { - continue - } - // Parse the diagnostic - var diag Diagnostic - diagBytes, err := json.Marshal(diagMap) - if err != nil { - continue - } - if err := json.Unmarshal(diagBytes, &diag); err != nil { - continue - } - // Only show error diagnostics (severity === 1) - if diag.Severity != 1 { - continue - } - line := diag.Range.Start.Line + 1 // 1-based - column := diag.Range.Start.Character + 1 // 1-based - errorDiagnostics = append( - errorDiagnostics, - fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message), - ) - } - if len(errorDiagnostics) == 0 { - return "" - } - t := theme.CurrentTheme() - var result strings.Builder - for _, diagnostic := range errorDiagnostics { - if result.Len() > 0 { - result.WriteString("\n\n") - } - diagnostic = ansi.WordwrapWc(diagnostic, width, " -") - result.WriteString( - styles.NewStyle(). - Background(backgroundColor). - Foreground(t.Error()). - Render(diagnostic), - ) - } - return result.String() - } - } - return "" - - // diagnosticsData should be a map[string][]Diagnostic - // strDiagnosticsData := diagnosticsData.Raw() - // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any) - // fileDiagnostics, ok := diagnosticsMap[filePath] - // if !ok { - // return "" - // } - - // diagnosticsList, ok := fileDiagnostics.([]any) - // if !ok { - // return "" - // } - -} diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go deleted file mode 100644 index 3d52b84e5..000000000 --- a/packages/tui/internal/components/chat/messages.go +++ /dev/null @@ -1,1322 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "log/slog" - "slices" - "sort" - "strconv" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/dialog" - "github.com/sst/opencode/internal/components/diff" - "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" - "github.com/sst/opencode/internal/viewport" -) - -type MessagesComponent interface { - tea.Model - tea.ViewModel - PageUp() (tea.Model, tea.Cmd) - PageDown() (tea.Model, tea.Cmd) - HalfPageUp() (tea.Model, tea.Cmd) - HalfPageDown() (tea.Model, tea.Cmd) - ToolDetailsVisible() bool - ThinkingBlocksVisible() bool - GotoTop() (tea.Model, tea.Cmd) - GotoBottom() (tea.Model, tea.Cmd) - CopyLastMessage() (tea.Model, tea.Cmd) - UndoLastMessage() (tea.Model, tea.Cmd) - RedoLastMessage() (tea.Model, tea.Cmd) - ScrollToMessage(messageID string) (tea.Model, tea.Cmd) -} - -type messagesComponent struct { - width, height int - app *app.App - header string - viewport viewport.Model - clipboard []string - cache *PartCache - loading bool - showToolDetails bool - showThinkingBlocks bool - rendering bool - dirty bool - tail bool - partCount int - lineCount int - selection *selection - messagePositions map[string]int // map message ID to line position - animating bool -} - -type selection struct { - startX int - endX int - startY int - endY int -} - -func (s selection) coords(offset int) *selection { - // selecting backwards - if s.startY > s.endY && s.endY >= 0 { - return &selection{ - startX: max(0, s.endX-1), - startY: s.endY - offset, - endX: s.startX + 1, - endY: s.startY - offset, - } - } - - // selecting backwards same line - if s.startY == s.endY && s.startX >= s.endX { - return &selection{ - startY: s.startY - offset, - startX: max(0, s.endX-1), - endY: s.endY - offset, - endX: s.startX + 1, - } - } - - return &selection{ - startX: s.startX, - startY: s.startY - offset, - endX: s.endX, - endY: s.endY - offset, - } -} - -type ToggleToolDetailsMsg struct{} -type ToggleThinkingBlocksMsg struct{} -type shimmerTickMsg struct{} - -func (m *messagesComponent) Init() tea.Cmd { - return tea.Batch(m.viewport.Init()) -} - -func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case shimmerTickMsg: - if !m.app.HasAnimatingWork() { - m.animating = false - return m, nil - } - return m, tea.Sequence( - m.renderView(), - tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }), - ) - case tea.MouseClickMsg: - slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset) - y := msg.Y + m.viewport.YOffset - if y > 0 { - m.selection = &selection{ - startY: y, - startX: msg.X, - endY: -1, - endX: -1, - } - - slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY)) - return m, m.renderView() - } - - case tea.MouseMotionMsg: - if m.selection != nil { - m.selection = &selection{ - startX: m.selection.startX, - startY: m.selection.startY, - endX: msg.X + 1, - endY: msg.Y + m.viewport.YOffset, - } - return m, m.renderView() - } - - case tea.MouseReleaseMsg: - if m.selection != nil { - m.selection = nil - if len(m.clipboard) > 0 { - content := strings.Join(m.clipboard, "\n") - m.clipboard = []string{} - return m, tea.Sequence( - m.renderView(), - app.SetClipboard(content), - toast.NewSuccessToast("Copied to clipboard"), - ) - } - return m, m.renderView() - } - case tea.WindowSizeMsg: - effectiveWidth := msg.Width - 4 - // Clear cache on resize since width affects rendering - if m.width != effectiveWidth { - m.cache.Clear() - } - m.width = effectiveWidth - m.height = msg.Height - 7 - m.viewport.SetWidth(m.width) - m.loading = true - return m, m.renderView() - case app.SendPrompt: - m.viewport.GotoBottom() - m.tail = true - return m, nil - case app.SendCommand: - m.viewport.GotoBottom() - m.tail = true - return m, nil - case dialog.ThemeSelectedMsg: - m.cache.Clear() - m.loading = true - return m, m.renderView() - case ToggleToolDetailsMsg: - m.showToolDetails = !m.showToolDetails - m.app.State.ShowToolDetails = &m.showToolDetails - return m, tea.Batch(m.renderView(), m.app.SaveState()) - case ToggleThinkingBlocksMsg: - m.showThinkingBlocks = !m.showThinkingBlocks - m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks - return m, tea.Batch(m.renderView(), m.app.SaveState()) - case app.SessionLoadedMsg: - m.tail = true - m.loading = true - return m, m.renderView() - case app.SessionClearedMsg: - m.cache.Clear() - m.tail = true - m.loading = true - return m, m.renderView() - case app.SessionUnrevertedMsg: - if msg.Session.ID == m.app.Session.ID { - m.cache.Clear() - m.tail = true - return m, m.renderView() - } - case app.SessionSelectedMsg: - currentParent := m.app.Session.ParentID - if currentParent == "" { - currentParent = m.app.Session.ID - } - - targetParent := msg.ParentID - if targetParent == "" { - targetParent = msg.ID - } - - // Clear cache only if switching between different session families - if currentParent != targetParent { - m.cache.Clear() - } - - m.viewport.GotoBottom() - case app.MessageRevertedMsg: - if msg.Session.ID == m.app.Session.ID { - m.cache.Clear() - m.tail = true - return m, m.renderView() - } - - case opencode.EventListResponseEventSessionUpdated: - if msg.Properties.Info.ID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessageUpdated: - if msg.Properties.Info.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventSessionError: - if msg.Properties.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessagePartUpdated: - if msg.Properties.Part.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessageRemoved: - if msg.Properties.SessionID == m.app.Session.ID { - m.cache.Clear() - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessagePartRemoved: - if msg.Properties.SessionID == m.app.Session.ID { - // Clear the cache when a part is removed to ensure proper re-rendering - m.cache.Clear() - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventPermissionUpdated: - m.tail = true - return m, m.renderView() - case opencode.EventListResponseEventPermissionReplied: - m.tail = true - return m, m.renderView() - case renderCompleteMsg: - m.partCount = msg.partCount - m.lineCount = msg.lineCount - m.rendering = false - m.clipboard = msg.clipboard - m.loading = false - m.messagePositions = msg.messagePositions - m.tail = m.viewport.AtBottom() - - // Preserve scroll across reflow - // if the user was at bottom, keep following; otherwise restore the previous offset. - wasAtBottom := m.viewport.AtBottom() - prevYOffset := m.viewport.YOffset - m.viewport = msg.viewport - if wasAtBottom { - m.viewport.GotoBottom() - } else { - m.viewport.YOffset = prevYOffset - } - - m.header = msg.header - if m.dirty { - cmds = append(cmds, m.renderView()) - } - - // Start shimmer ticks if any assistant/tool is in-flight - if !m.animating && m.app.HasAnimatingWork() { - m.animating = true - cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} })) - } - } - - m.tail = m.viewport.AtBottom() - viewport, cmd := m.viewport.Update(msg) - m.viewport = viewport - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -type renderCompleteMsg struct { - viewport viewport.Model - clipboard []string - header string - partCount int - lineCount int - messagePositions map[string]int -} - -func (m *messagesComponent) renderView() tea.Cmd { - if m.rendering { - slog.Debug("pending render, skipping") - m.dirty = true - return func() tea.Msg { - return nil - } - } - m.dirty = false - m.rendering = true - - viewport := m.viewport - tail := m.tail - - return func() tea.Msg { - header := m.renderHeader() - measure := util.Measure("messages.renderView") - defer measure() - - t := theme.CurrentTheme() - blocks := make([]string, 0) - partCount := 0 - lineCount := 0 - messagePositions := make(map[string]int) // Track message ID to line position - - orphanedToolCalls := make([]opencode.ToolPart, 0) - - width := m.width // always use full width - - // Find the last streaming ReasoningPart to only shimmer that one - lastStreamingReasoningID := "" - if m.showThinkingBlocks { - for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- { - if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok { - continue - } - parts := m.app.Messages[mi].Parts - for pi := len(parts) - 1; pi >= 0; pi-- { - if rp, ok := parts[pi].(opencode.ReasoningPart); ok { - if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 { - lastStreamingReasoningID = rp.ID - break - } - } - } - } - } - - reverted := false - revertedMessageCount := 0 - revertedToolCount := 0 - lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" - for _, msg := range slices.Backward(m.app.Messages) { - if assistant, ok := msg.Info.(opencode.AssistantMessage); ok { - if assistant.Time.Completed > 0 { - break - } - lastAssistantMessage = assistant.ID - break - } - } - for _, message := range m.app.Messages { - var content string - var cached bool - error := "" - - switch casted := message.Info.(type) { - case opencode.UserMessage: - // Track the position of this user message - messagePositions[casted.ID] = lineCount - - if casted.ID == m.app.Session.Revert.MessageID { - reverted = true - revertedMessageCount = 1 - revertedToolCount = 0 - continue - } - if reverted { - revertedMessageCount++ - continue - } - - for partIndex, part := range message.Parts { - switch part := part.(type) { - case opencode.TextPart: - if part.Synthetic { - continue - } - if part.Text == "" { - continue - } - remainingParts := message.Parts[partIndex+1:] - fileParts := make([]opencode.FilePart, 0) - agentParts := make([]opencode.AgentPart, 0) - for _, part := range remainingParts { - switch part := part.(type) { - case opencode.FilePart: - if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start { - fileParts = append(fileParts, part) - } - case opencode.AgentPart: - if part.Source.Start >= 0 && part.Source.End >= part.Source.Start { - agentParts = append(agentParts, part) - } - } - } - flexItems := []layout.FlexItem{} - if len(fileParts) > 0 { - fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) - mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) - for _, filePart := range fileParts { - mediaType := "" - switch filePart.Mime { - case "text/plain": - mediaType = "txt" - case "image/png", "image/jpeg", "image/gif", "image/webp": - mediaType = "img" - mediaTypeStyle = mediaTypeStyle.Background(t.Accent()) - case "application/pdf": - mediaType = "pdf" - mediaTypeStyle = mediaTypeStyle.Background(t.Primary()) - } - flexItems = append(flexItems, layout.FlexItem{ - View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), - }) - } - } - bgColor := t.BackgroundPanel() - files := layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Width: width - 6, - Direction: layout.Column, - }, - flexItems..., - ) - - author := m.app.Config.Username - isQueued := casted.ID > lastAssistantMessage - key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued) - content, cached = m.cache.Get(key) - if !cached { - content = renderText( - m.app, - message.Info, - part.Text, - author, - m.showToolDetails, - width, - files, - false, - isQueued, - false, - fileParts, - agentParts, - ) - m.cache.Set(key, content) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - } - - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - reverted = true - revertedMessageCount = 1 - revertedToolCount = 0 - } - hasTextPart := false - hasContent := false - for partIndex, p := range message.Parts { - switch part := p.(type) { - case opencode.TextPart: - if reverted { - continue - } - if strings.TrimSpace(part.Text) == "" { - continue - } - hasTextPart = true - finished := part.Time.End > 0 - remainingParts := message.Parts[partIndex+1:] - toolCallParts := make([]opencode.ToolPart, 0) - - // sometimes tool calls happen without an assistant message - // these should be included in this assistant message as well - if len(orphanedToolCalls) > 0 { - toolCallParts = append(toolCallParts, orphanedToolCalls...) - orphanedToolCalls = make([]opencode.ToolPart, 0) - } - - remaining := true - for _, part := range remainingParts { - if !remaining { - break - } - switch part := part.(type) { - case opencode.TextPart: - // we only want tool calls associated with the current text part. - // if we hit another text part, we're done. - remaining = false - case opencode.ToolPart: - toolCallParts = append(toolCallParts, part) - if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError { - // i don't think there's a case where a tool call isn't in result state - // and the message time is 0, but just in case - finished = false - } - } - } - - if finished { - key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts) - content, cached = m.cache.Get(key) - if !cached { - content = renderText( - m.app, - message.Info, - part.Text, - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - toolCallParts..., - ) - m.cache.Set(key, content) - } - } else { - content = renderText( - m.app, - message.Info, - part.Text, - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - toolCallParts..., - ) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - case opencode.ToolPart: - if reverted { - revertedToolCount++ - continue - } - - permission := opencode.Permission{} - if m.app.CurrentPermission.CallID == part.CallID { - permission = m.app.CurrentPermission - } - - if !m.showToolDetails && permission.ID == "" { - if !hasTextPart { - orphanedToolCalls = append(orphanedToolCalls, part) - } - continue - } - - if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError { - key := m.cache.GenerateKey(casted.ID, - part.ID, - m.showToolDetails, - width, - permission.ID, - ) - content, cached = m.cache.Get(key) - if !cached { - content = renderToolDetails( - m.app, - part, - permission, - width, - ) - m.cache.Set(key, content) - } - } else { - // if the tool call isn't finished, don't cache - content = renderToolDetails( - m.app, - part, - permission, - width, - ) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - case opencode.ReasoningPart: - if reverted { - continue - } - if !m.showThinkingBlocks { - continue - } - if part.Text != "" { - text := part.Text - shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID - content = renderText( - m.app, - message.Info, - text, - casted.ModelID, - m.showToolDetails, - width, - "", - true, - false, - shimmer, - []opencode.FilePart{}, - []opencode.AgentPart{}, - ) - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - } - } - - switch err := casted.Error.AsUnion().(type) { - case nil: - case opencode.AssistantMessageErrorMessageOutputLengthError: - error = "Message output length exceeded" - case opencode.AssistantMessageErrorAPIError: - error = err.Data.Message - case opencode.ProviderAuthError: - error = err.Data.Message - case opencode.MessageAbortedError: - error = "Request was aborted" - case opencode.UnknownError: - error = err.Data.Message - } - - if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 { - content = renderText( - m.app, - message.Info, - "Generating...", - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - ) - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - - if error != "" && !reverted { - error = styles.NewStyle().Width(width - 6).Render(error) - error = renderContentBlock( - m.app, - error, - width, - WithBorderColor(t.Error()), - ) - blocks = append(blocks, error) - lineCount += lipgloss.Height(error) + 1 - } - } - - if revertedMessageCount > 0 || revertedToolCount > 0 { - messagePlural := "" - toolPlural := "" - if revertedMessageCount != 1 { - messagePlural = "s" - } - if revertedToolCount != 1 { - toolPlural = "s" - } - revertedStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.TextMuted()) - - content := revertedStyle.Render(fmt.Sprintf( - "%d message%s reverted, %d tool call%s reverted", - revertedMessageCount, - messagePlural, - revertedToolCount, - toolPlural, - )) - hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text()) - hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand)) - hint += revertedStyle.Render(" (or /redo) to restore") - - content += "\n" + hint - if m.app.Session.Revert.Diff != "" { - t := theme.CurrentTheme() - s := styles.NewStyle().Background(t.BackgroundPanel()) - green := s.Foreground(t.Success()).Render - red := s.Foreground(t.Error()).Render - content += "\n" - stats, err := diff.ParseStats(m.app.Session.Revert.Diff) - if err != nil { - slog.Error("Failed to parse diff stats", "error", err) - } else { - var files []string - for file := range stats { - files = append(files, file) - } - sort.Strings(files) - - for _, file := range files { - fileStats := stats[file] - display := file - if fileStats.Added > 0 { - display += green(" +" + strconv.Itoa(int(fileStats.Added))) - } - if fileStats.Removed > 0 { - display += red(" -" + strconv.Itoa(int(fileStats.Removed))) - } - content += "\n" + display - } - } - } - - content = styles.NewStyle(). - Background(t.BackgroundPanel()). - Width(width - 6). - Render(content) - content = renderContentBlock( - m.app, - content, - width, - WithBorderColor(t.BackgroundPanel()), - ) - blocks = append(blocks, content) - } - - if m.app.CurrentPermission.ID != "" && - m.app.CurrentPermission.SessionID != m.app.Session.ID { - response, err := m.app.Client.Session.Message( - context.Background(), - m.app.CurrentPermission.SessionID, - m.app.CurrentPermission.MessageID, - opencode.SessionMessageParams{}, - ) - if err != nil || response == nil { - slog.Error("Failed to get message from child session", "error", err) - } else { - for _, part := range response.Parts { - if part.CallID == m.app.CurrentPermission.CallID { - if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok { - content := renderToolDetails( - m.app, - toolPart, - m.app.CurrentPermission, - width, - ) - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - } - } - } - } - - final := []string{} - clipboard := []string{} - var selection *selection - if m.selection != nil { - selection = m.selection.coords(lipgloss.Height(header) + 1) - } - for _, block := range blocks { - lines := strings.Split(block, "\n") - for index, line := range lines { - if selection == nil || index == 0 || index == len(lines)-1 { - final = append(final, line) - continue - } - y := len(final) - if y >= selection.startY && y <= selection.endY { - left := 3 - if y == selection.startY { - left = selection.startX - 2 - } - left = max(3, left) - - width := ansi.StringWidth(line) - right := width - 1 - if y == selection.endY { - right = min(selection.endX-2, right) - } - - prefix := ansi.Cut(line, 0, left) - middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ") - suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width) - clipboard = append(clipboard, middle) - line = prefix + styles.NewStyle(). - Background(t.Accent()). - Foreground(t.BackgroundPanel()). - Render(ansi.Strip(middle)) + - suffix - } - final = append(final, line) - } - y := len(final) - if selection != nil && y >= selection.startY && y < selection.endY { - clipboard = append(clipboard, "") - } - final = append(final, "") - } - content := "\n" + strings.Join(final, "\n") - viewport.SetHeight(m.height - lipgloss.Height(header)) - viewport.SetContent(content) - if tail { - viewport.GotoBottom() - } - - return renderCompleteMsg{ - header: header, - clipboard: clipboard, - viewport: viewport, - partCount: partCount, - lineCount: lineCount, - messagePositions: messagePositions, - } - } -} - -func (m *messagesComponent) renderHeader() string { - if m.app.Session.ID == "" { - return "" - } - - headerWidth := m.width - - t := theme.CurrentTheme() - bgColor := t.Background() - borderColor := t.BackgroundElement() - - isChildSession := m.app.Session.ParentID != "" - if isChildSession { - bgColor = t.BackgroundElement() - borderColor = t.Accent() - } - - base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render - muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render - - sessionInfo := "" - tokens := float64(0) - cost := float64(0) - contextWindow := m.app.Model.Limit.Context - - for _, message := range m.app.Messages { - if assistant, ok := message.Info.(opencode.AssistantMessage); ok { - cost += assistant.Cost - usage := assistant.Tokens - if usage.Output > 0 { - if assistant.Summary { - tokens = usage.Output - continue - } - tokens = (usage.Input + - usage.Cache.Read + - usage.Cache.Write + - usage.Output + - usage.Reasoning) - } - } - } - - // Check if current model is a subscription model (cost is 0 for both input and output) - isSubscriptionModel := m.app.Model != nil && - m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0 - - sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel) - sessionInfo = styles.NewStyle(). - Foreground(t.TextMuted()). - Background(bgColor). - Render(sessionInfoText) - - shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled - - navHint := "" - if isChildSession { - navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back") - } - - headerTextWidth := headerWidth - if isChildSession { - headerTextWidth -= lipgloss.Width(navHint) - } else if !shareEnabled { - headerTextWidth -= lipgloss.Width(sessionInfoText) - } - headerText := util.ToMarkdown( - "# "+m.app.Session.Title, - headerTextWidth, - bgColor, - ) - if isChildSession { - headerText = layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: headerTextWidth, - }, - layout.FlexItem{ - View: headerText, - }, - layout.FlexItem{ - View: navHint, - }, - ) - } - - var items []layout.FlexItem - if shareEnabled { - share := base("/share") + muted(" to create a shareable link") - if m.app.Session.Share.URL != "" { - share = muted(m.app.Session.Share.URL + " /unshare") - } - items = []layout.FlexItem{{View: share}, {View: sessionInfo}} - } else { - items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}} - } - - headerRow := layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: headerWidth - 6, - }, - items..., - ) - - headerLines := []string{headerRow} - if shareEnabled { - headerLines = []string{headerText, headerRow} - } - - header := strings.Join(headerLines, "\n") - header = styles.NewStyle(). - Background(bgColor). - Width(headerWidth). - PaddingLeft(2). - PaddingRight(2). - BorderLeft(true). - BorderRight(true). - BorderBackground(t.Background()). - BorderForeground(borderColor). - BorderStyle(lipgloss.ThickBorder()). - Render(header) - - return "\n" + header + "\n" -} - -func formatTokensAndCost( - tokens float64, - contextWindow float64, - cost float64, - isSubscriptionModel bool, -) string { - // Format tokens in human-readable format (e.g., 110K, 1.2M) - var formattedTokens string - switch { - case tokens >= 1_000_000: - formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) - case tokens >= 1_000: - formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) - default: - formattedTokens = fmt.Sprintf("%d", int(tokens)) - } - - // Remove .0 suffix if present - if strings.HasSuffix(formattedTokens, ".0K") { - formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) - } - if strings.HasSuffix(formattedTokens, ".0M") { - formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) - } - - percentage := 0.0 - if contextWindow > 0 { - percentage = (float64(tokens) / float64(contextWindow)) * 100 - } - - if isSubscriptionModel { - return fmt.Sprintf( - "%s/%d%%", - formattedTokens, - int(percentage), - ) - } - - formattedCost := fmt.Sprintf("$%.2f", cost) - return fmt.Sprintf( - " %s/%d%% (%s)", - formattedTokens, - int(percentage), - formattedCost, - ) -} - -func (m *messagesComponent) View() string { - t := theme.CurrentTheme() - bgColor := t.Background() - - if m.loading { - return lipgloss.Place( - m.width, - m.height, - lipgloss.Center, - lipgloss.Center, - styles.NewStyle().Background(bgColor).Render(""), - styles.WhitespaceStyle(bgColor), - ) - } - - viewport := m.viewport.View() - return styles.NewStyle(). - Background(bgColor). - Render(m.header + "\n" + viewport) -} - -func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) { - m.viewport.ViewUp() - return m, nil -} - -func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) { - m.viewport.ViewDown() - return m, nil -} - -func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) { - m.viewport.HalfViewUp() - return m, nil -} - -func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { - m.viewport.HalfViewDown() - return m, nil -} - -func (m *messagesComponent) ToolDetailsVisible() bool { - return m.showToolDetails -} - -func (m *messagesComponent) ThinkingBlocksVisible() bool { - return m.showThinkingBlocks -} - -func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() - return m, nil -} - -func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() - return m, nil -} - -func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) { - if len(m.app.Messages) == 0 { - return m, nil - } - lastMessage := m.app.Messages[len(m.app.Messages)-1] - var lastTextPart *opencode.TextPart - for _, part := range lastMessage.Parts { - if p, ok := part.(opencode.TextPart); ok { - lastTextPart = &p - } - } - if lastTextPart == nil { - return m, nil - } - var cmds []tea.Cmd - cmds = append(cmds, app.SetClipboard(lastTextPart.Text)) - cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard")) - return m, tea.Batch(cmds...) -} - -func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) { - after := float64(0) - var revertedMessage app.Message - reversedMessages := []app.Message{} - for i := len(m.app.Messages) - 1; i >= 0; i-- { - reversedMessages = append(reversedMessages, m.app.Messages[i]) - switch casted := m.app.Messages[i].Info.(type) { - case opencode.UserMessage: - if casted.ID == m.app.Session.Revert.MessageID { - after = casted.Time.Created - } - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - after = casted.Time.Created - } - } - if m.app.Session.Revert.PartID != "" { - for _, part := range m.app.Messages[i].Parts { - switch casted := part.(type) { - case opencode.TextPart: - if casted.ID == m.app.Session.Revert.PartID { - after = casted.Time.Start - } - case opencode.ToolPart: - // TODO: handle tool parts - } - } - } - } - - messageID := "" - for _, msg := range reversedMessages { - switch casted := msg.Info.(type) { - case opencode.UserMessage: - if after > 0 && casted.Time.Created >= after { - continue - } - messageID = casted.ID - revertedMessage = msg - } - if messageID != "" { - break - } - } - - if messageID == "" { - return m, nil - } - - return m, func() tea.Msg { - response, err := m.app.Client.Session.Revert( - context.Background(), - m.app.Session.ID, - opencode.SessionRevertParams{ - MessageID: opencode.F(messageID), - }, - ) - if err != nil { - slog.Error("Failed to undo message", "error", err) - return toast.NewErrorToast("Failed to undo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to undo message")() - } - return app.MessageRevertedMsg{Session: *response, Message: revertedMessage} - } -} - -func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) { - // Check if there's a revert state to redo from - if m.app.Session.Revert.MessageID == "" { - return m, func() tea.Msg { - return toast.NewErrorToast("Nothing to redo") - } - } - - before := float64(0) - var revertedMessage app.Message - for _, message := range m.app.Messages { - switch casted := message.Info.(type) { - case opencode.UserMessage: - if casted.ID == m.app.Session.Revert.MessageID { - before = casted.Time.Created - } - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - before = casted.Time.Created - } - } - if m.app.Session.Revert.PartID != "" { - for _, part := range message.Parts { - switch casted := part.(type) { - case opencode.TextPart: - if casted.ID == m.app.Session.Revert.PartID { - before = casted.Time.Start - } - case opencode.ToolPart: - // TODO: handle tool parts - } - } - } - } - - messageID := "" - for _, msg := range m.app.Messages { - switch casted := msg.Info.(type) { - case opencode.UserMessage: - if casted.Time.Created <= before { - continue - } - messageID = casted.ID - revertedMessage = msg - } - if messageID != "" { - break - } - } - - if messageID == "" { - return m, func() tea.Msg { - // unrevert back to original state - response, err := m.app.Client.Session.Unrevert( - context.Background(), - m.app.Session.ID, - opencode.SessionUnrevertParams{}, - ) - if err != nil { - slog.Error("Failed to unrevert session", "error", err) - return toast.NewErrorToast("Failed to redo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to redo message")() - } - return app.SessionUnrevertedMsg{Session: *response} - } - } - - return m, func() tea.Msg { - // calling revert on a "later" message is like a redo - response, err := m.app.Client.Session.Revert( - context.Background(), - m.app.Session.ID, - opencode.SessionRevertParams{ - MessageID: opencode.F(messageID), - }, - ) - if err != nil { - slog.Error("Failed to redo message", "error", err) - return toast.NewErrorToast("Failed to redo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to redo message")() - } - return app.MessageRevertedMsg{Session: *response, Message: revertedMessage} - } -} - -func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) { - if m.messagePositions == nil { - return m, nil - } - - if position, exists := m.messagePositions[messageID]; exists { - m.viewport.SetYOffset(position) - m.tail = false // Stop auto-scrolling to bottom when manually navigating - } - return m, nil -} - -func NewMessagesComponent(app *app.App) MessagesComponent { - vp := viewport.New() - vp.KeyMap = viewport.KeyMap{} - - if app.ScrollSpeed > 0 { - vp.MouseWheelDelta = app.ScrollSpeed - } else { - vp.MouseWheelDelta = 2 - } - - // Default to showing tool details, hidden thinking blocks - showToolDetails := true - if app.State.ShowToolDetails != nil { - showToolDetails = *app.State.ShowToolDetails - } - - showThinkingBlocks := false - if app.State.ShowThinkingBlocks != nil { - showThinkingBlocks = *app.State.ShowThinkingBlocks - } - - return &messagesComponent{ - app: app, - viewport: vp, - showToolDetails: showToolDetails, - showThinkingBlocks: showThinkingBlocks, - cache: NewPartCache(), - tail: true, - messagePositions: make(map[string]int), - } -} diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go deleted file mode 100644 index fd578a41b..000000000 --- a/packages/tui/internal/components/commands/commands.go +++ /dev/null @@ -1,247 +0,0 @@ -package commands - -import ( - "fmt" - "runtime" - "strings" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -type CommandsComponent interface { - tea.ViewModel - SetSize(width, height int) tea.Cmd - SetBackgroundColor(color compat.AdaptiveColor) -} - -type commandsComponent struct { - app *app.App - width, height int - showKeybinds bool - showAll bool - showVscode bool - background *compat.AdaptiveColor - limit *int -} - -func (c *commandsComponent) SetSize(width, height int) tea.Cmd { - c.width = width - c.height = height - return nil -} - -func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { - c.background = &color -} - -func (c *commandsComponent) View() string { - t := theme.CurrentTheme() - - triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true) - descriptionStyle := styles.NewStyle().Foreground(t.Text()) - keybindStyle := styles.NewStyle().Foreground(t.TextMuted()) - - if c.background != nil { - triggerStyle = triggerStyle.Background(*c.background) - descriptionStyle = descriptionStyle.Background(*c.background) - keybindStyle = keybindStyle.Background(*c.background) - } - - var commandsToShow []commands.Command - var triggeredCommands []commands.Command - var untriggeredCommands []commands.Command - - for _, cmd := range c.app.Commands.Sorted() { - if c.showAll || cmd.HasTrigger() { - if cmd.HasTrigger() { - triggeredCommands = append(triggeredCommands, cmd) - } else if c.showAll { - untriggeredCommands = append(untriggeredCommands, cmd) - } - } - } - - // Combine triggered commands first, then untriggered - commandsToShow = append(commandsToShow, triggeredCommands...) - commandsToShow = append(commandsToShow, untriggeredCommands...) - - if c.limit != nil && len(commandsToShow) > *c.limit { - commandsToShow = commandsToShow[:*c.limit] - } - - if c.showVscode { - ctrlKey := "ctrl" - if runtime.GOOS == "darwin" { - ctrlKey = "cmd" - } - commandsToShow = append(commandsToShow, - // empty line - // commands.Command{ - // Name: "", - // Description: "", - // }, - commands.Command{ - Name: commands.CommandName(util.Ide()), - Description: "open opencode", - Keybindings: []commands.Keybinding{ - {Key: ctrlKey + "+esc", RequiresLeader: false}, - }, - }, - commands.Command{ - Name: commands.CommandName(util.Ide()), - Description: "reference file", - Keybindings: []commands.Keybinding{ - {Key: ctrlKey + "+opt+k", RequiresLeader: false}, - }, - }, - ) - } - - if len(commandsToShow) == 0 { - muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted()) - if c.showAll { - return muted.Render("No commands available") - } - return muted.Render("No commands with triggers available") - } - - // Calculate column widths - maxTriggerWidth := 0 - maxDescriptionWidth := 0 - maxKeybindWidth := 0 - - // Prepare command data - type commandRow struct { - trigger string - description string - keybinds string - } - - rows := make([]commandRow, 0, len(commandsToShow)) - - for _, cmd := range commandsToShow { - trigger := "" - if cmd.HasTrigger() { - trigger = "/" + cmd.PrimaryTrigger() - } else { - trigger = string(cmd.Name) - } - description := cmd.Description - - // Format keybindings - var keybindStrs []string - if c.showKeybinds { - for _, kb := range cmd.Keybindings { - if kb.RequiresLeader { - keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key) - } else { - keybindStrs = append(keybindStrs, kb.Key) - } - } - } - keybinds := strings.Join(keybindStrs, ", ") - - rows = append(rows, commandRow{ - trigger: trigger, - description: description, - keybinds: keybinds, - }) - - // Update max widths - if len(trigger) > maxTriggerWidth { - maxTriggerWidth = len(trigger) - } - if len(description) > maxDescriptionWidth { - maxDescriptionWidth = len(description) - } - if len(keybinds) > maxKeybindWidth { - maxKeybindWidth = len(keybinds) - } - } - - // Add padding between columns - columnPadding := 3 - - // Build the output - var output strings.Builder - - maxWidth := 0 - for _, row := range rows { - // Pad each column to align properly - trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger) - description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description) - - // Apply styles and combine - line := triggerStyle.Render(trigger) + - triggerStyle.Render(strings.Repeat(" ", columnPadding)) + - descriptionStyle.Render(description) - - if c.showKeybinds && row.keybinds != "" { - line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) + - keybindStyle.Render(row.keybinds) - } - - output.WriteString(line + "\n") - maxWidth = max(maxWidth, lipgloss.Width(line)) - } - - // Remove trailing newline - result := strings.TrimSuffix(output.String(), "\n") - if c.background != nil { - result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result) - } - - return result -} - -type Option func(*commandsComponent) - -func WithKeybinds(show bool) Option { - return func(c *commandsComponent) { - c.showKeybinds = show - } -} - -func WithBackground(background compat.AdaptiveColor) Option { - return func(c *commandsComponent) { - c.background = &background - } -} - -func WithLimit(limit int) Option { - return func(c *commandsComponent) { - c.limit = &limit - } -} - -func WithShowAll(showAll bool) Option { - return func(c *commandsComponent) { - c.showAll = showAll - } -} - -func WithVscode(showVscode bool) Option { - return func(c *commandsComponent) { - c.showVscode = showVscode - } -} - -func New(app *app.App, opts ...Option) CommandsComponent { - c := &commandsComponent{ - app: app, - background: nil, - showKeybinds: true, - showAll: false, - } - for _, opt := range opts { - opt(c) - } - return c -} 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), - ), - } -} diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go deleted file mode 100644 index da2e007c2..000000000 --- a/packages/tui/internal/components/diff/diff.go +++ /dev/null @@ -1,957 +0,0 @@ -package diff - -import ( - "bufio" - "bytes" - "fmt" - "image/color" - "io" - "regexp" - "strconv" - "strings" - "sync" - "unicode/utf8" - - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" - "github.com/sergi/go-diff/diffmatchpatch" - stylesi "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -// ------------------------------------------------------------------------- -// Core Types -// ------------------------------------------------------------------------- - -// LineType represents the kind of line in a diff. -type LineType int - -const ( - LineContext LineType = iota // Line exists in both files - LineAdded // Line added in the new file - LineRemoved // Line removed from the old file -) - -var ( - ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`) -) - -// Segment represents a portion of a line for intra-line highlighting -type Segment struct { - Start int - End int - Type LineType - Text string -} - -// DiffLine represents a single line in a diff -type DiffLine struct { - OldLineNo int // Line number in old file (0 for added lines) - NewLineNo int // Line number in new file (0 for removed lines) - Kind LineType // Type of line (added, removed, context) - Content string // Content of the line - Segments []Segment // Segments for intraline highlighting -} - -// Hunk represents a section of changes in a diff -type Hunk struct { - Header string - Lines []DiffLine -} - -// DiffResult contains the parsed result of a diff -type DiffResult struct { - OldFile string - NewFile string - Hunks []Hunk -} - -// linePair represents a pair of lines for side-by-side display -type linePair struct { - left *DiffLine - right *DiffLine -} - -// UnifiedConfig configures the rendering of unified diffs -type UnifiedConfig struct { - Width int -} - -// UnifiedOption modifies a UnifiedConfig -type UnifiedOption func(*UnifiedConfig) - -// NewUnifiedConfig creates a UnifiedConfig with default values -func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { - config := UnifiedConfig{ - Width: 80, - } - for _, opt := range opts { - opt(&config) - } - return config -} - -// NewSideBySideConfig creates a SideBySideConfig with default values -func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig { - config := UnifiedConfig{ - Width: 160, - } - for _, opt := range opts { - opt(&config) - } - return config -} - -// WithWidth sets the width for unified view -func WithWidth(width int) UnifiedOption { - return func(u *UnifiedConfig) { - if width > 0 { - u.Width = width - } - } -} - -// ------------------------------------------------------------------------- -// Diff Parsing -// ------------------------------------------------------------------------- - -// ParseUnifiedDiff parses a unified diff format string into structured data -func ParseUnifiedDiff(diff string) (DiffResult, error) { - var result DiffResult - var currentHunk *Hunk - result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity - - scanner := bufio.NewScanner(strings.NewReader(diff)) - var oldLine, newLine int - inFileHeader := true - - for scanner.Scan() { - line := scanner.Text() - - if inFileHeader { - if strings.HasPrefix(line, "--- a/") { - result.OldFile = line[6:] - continue - } - if strings.HasPrefix(line, "+++ b/") { - result.NewFile = line[6:] - inFileHeader = false - continue - } - } - - if strings.HasPrefix(line, "@@") { - if currentHunk != nil { - result.Hunks = append(result.Hunks, *currentHunk) - } - currentHunk = &Hunk{ - Header: line, - Lines: make([]DiffLine, 0, 10), // Pre-allocate - } - - // Manual parsing of hunk header is faster than regex - parts := strings.Split(line, " ") - if len(parts) > 2 { - oldRange := strings.Split(parts[1][1:], ",") - newRange := strings.Split(parts[2][1:], ",") - oldLine, _ = strconv.Atoi(oldRange[0]) - newLine, _ = strconv.Atoi(newRange[0]) - } - continue - } - - if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil { - continue - } - - var dl DiffLine - dl.Content = line - if len(line) > 0 { - switch line[0] { - case '+': - dl.Kind = LineAdded - dl.NewLineNo = newLine - dl.Content = line[1:] - newLine++ - case '-': - dl.Kind = LineRemoved - dl.OldLineNo = oldLine - dl.Content = line[1:] - oldLine++ - default: // context line - dl.Kind = LineContext - dl.OldLineNo = oldLine - dl.NewLineNo = newLine - oldLine++ - newLine++ - } - } else { // empty context line - dl.Kind = LineContext - dl.OldLineNo = oldLine - dl.NewLineNo = newLine - oldLine++ - newLine++ - } - currentHunk.Lines = append(currentHunk.Lines, dl) - } - - if currentHunk != nil { - result.Hunks = append(result.Hunks, *currentHunk) - } - - return result, scanner.Err() -} - -// HighlightIntralineChanges updates lines in a hunk to show character-level differences -func HighlightIntralineChanges(h *Hunk) { - var updated []DiffLine - dmp := diffmatchpatch.New() - - for i := 0; i < len(h.Lines); i++ { - // Look for removed line followed by added line - if i+1 < len(h.Lines) && - h.Lines[i].Kind == LineRemoved && - h.Lines[i+1].Kind == LineAdded { - - oldLine := h.Lines[i] - newLine := h.Lines[i+1] - - // Find character-level differences - patches := dmp.DiffMain(oldLine.Content, newLine.Content, false) - patches = dmp.DiffCleanupSemantic(patches) - patches = dmp.DiffCleanupMerge(patches) - patches = dmp.DiffCleanupEfficiency(patches) - - segments := make([]Segment, 0) - - removeStart := 0 - addStart := 0 - for _, patch := range patches { - switch patch.Type { - case diffmatchpatch.DiffDelete: - segments = append(segments, Segment{ - Start: removeStart, - End: removeStart + len(patch.Text), - Type: LineRemoved, - Text: patch.Text, - }) - removeStart += len(patch.Text) - case diffmatchpatch.DiffInsert: - segments = append(segments, Segment{ - Start: addStart, - End: addStart + len(patch.Text), - Type: LineAdded, - Text: patch.Text, - }) - addStart += len(patch.Text) - default: - // Context text, no highlighting needed - removeStart += len(patch.Text) - addStart += len(patch.Text) - } - } - oldLine.Segments = segments - newLine.Segments = segments - - updated = append(updated, oldLine, newLine) - i++ // Skip the next line as we've already processed it - } else { - updated = append(updated, h.Lines[i]) - } - } - - h.Lines = updated -} - -// pairLines converts a flat list of diff lines to pairs for side-by-side display -func pairLines(lines []DiffLine) []linePair { - var pairs []linePair - i := 0 - - for i < len(lines) { - switch lines[i].Kind { - case LineRemoved: - // Check if the next line is an addition, if so pair them - if i+1 < len(lines) && lines[i+1].Kind == LineAdded { - pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]}) - i += 2 - } else { - pairs = append(pairs, linePair{left: &lines[i], right: nil}) - i++ - } - case LineAdded: - pairs = append(pairs, linePair{left: nil, right: &lines[i]}) - i++ - case LineContext: - pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]}) - i++ - } - } - - return pairs -} - -// ------------------------------------------------------------------------- -// Syntax Highlighting -// ------------------------------------------------------------------------- - -// SyntaxHighlight applies syntax highlighting to text based on file extension -func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error { - t := theme.CurrentTheme() - - // Determine the language lexer to use - l := lexers.Match(fileName) - if l == nil { - l = lexers.Analyse(source) - } - if l == nil { - l = lexers.Fallback - } - l = chroma.Coalesce(l) - - // Get the formatter - f := formatters.Get(formatter) - if f == nil { - f = formatters.Fallback - } - - // Dynamic theme based on current theme values - syntaxThemeXml := fmt.Sprintf(` - <style name="opencode-theme"> - <!-- Base colors --> - <entry type="Background" style="bg:%s"/> - <entry type="Text" style="%s"/> - <entry type="Other" style="%s"/> - <entry type="Error" style="%s"/> - <!-- Keywords --> - <entry type="Keyword" style="%s"/> - <entry type="KeywordConstant" style="%s"/> - <entry type="KeywordDeclaration" style="%s"/> - <entry type="KeywordNamespace" style="%s"/> - <entry type="KeywordPseudo" style="%s"/> - <entry type="KeywordReserved" style="%s"/> - <entry type="KeywordType" style="%s"/> - <!-- Names --> - <entry type="Name" style="%s"/> - <entry type="NameAttribute" style="%s"/> - <entry type="NameBuiltin" style="%s"/> - <entry type="NameBuiltinPseudo" style="%s"/> - <entry type="NameClass" style="%s"/> - <entry type="NameConstant" style="%s"/> - <entry type="NameDecorator" style="%s"/> - <entry type="NameEntity" style="%s"/> - <entry type="NameException" style="%s"/> - <entry type="NameFunction" style="%s"/> - <entry type="NameLabel" style="%s"/> - <entry type="NameNamespace" style="%s"/> - <entry type="NameOther" style="%s"/> - <entry type="NameTag" style="%s"/> - <entry type="NameVariable" style="%s"/> - <entry type="NameVariableClass" style="%s"/> - <entry type="NameVariableGlobal" style="%s"/> - <entry type="NameVariableInstance" style="%s"/> - <!-- Literals --> - <entry type="Literal" style="%s"/> - <entry type="LiteralDate" style="%s"/> - <entry type="LiteralString" style="%s"/> - <entry type="LiteralStringBacktick" style="%s"/> - <entry type="LiteralStringChar" style="%s"/> - <entry type="LiteralStringDoc" style="%s"/> - <entry type="LiteralStringDouble" style="%s"/> - <entry type="LiteralStringEscape" style="%s"/> - <entry type="LiteralStringHeredoc" style="%s"/> - <entry type="LiteralStringInterpol" style="%s"/> - <entry type="LiteralStringOther" style="%s"/> - <entry type="LiteralStringRegex" style="%s"/> - <entry type="LiteralStringSingle" style="%s"/> - <entry type="LiteralStringSymbol" style="%s"/> - <!-- Numbers --> - <entry type="LiteralNumber" style="%s"/> - <entry type="LiteralNumberBin" style="%s"/> - <entry type="LiteralNumberFloat" style="%s"/> - <entry type="LiteralNumberHex" style="%s"/> - <entry type="LiteralNumberInteger" style="%s"/> - <entry type="LiteralNumberIntegerLong" style="%s"/> - <entry type="LiteralNumberOct" style="%s"/> - <!-- Operators --> - <entry type="Operator" style="%s"/> - <entry type="OperatorWord" style="%s"/> - <entry type="Punctuation" style="%s"/> - <!-- Comments --> - <entry type="Comment" style="%s"/> - <entry type="CommentHashbang" style="%s"/> - <entry type="CommentMultiline" style="%s"/> - <entry type="CommentSingle" style="%s"/> - <entry type="CommentSpecial" style="%s"/> - <entry type="CommentPreproc" style="%s"/> - <!-- Generic styles --> - <entry type="Generic" style="%s"/> - <entry type="GenericDeleted" style="%s"/> - <entry type="GenericEmph" style="italic %s"/> - <entry type="GenericError" style="%s"/> - <entry type="GenericHeading" style="bold %s"/> - <entry type="GenericInserted" style="%s"/> - <entry type="GenericOutput" style="%s"/> - <entry type="GenericPrompt" style="%s"/> - <entry type="GenericStrong" style="bold %s"/> - <entry type="GenericSubheading" style="bold %s"/> - <entry type="GenericTraceback" style="%s"/> - <entry type="GenericUnderline" style="underline"/> - <entry type="TextWhitespace" style="%s"/> -</style> -`, - getChromaColor(t.BackgroundPanel()), // Background - getChromaColor(t.Text()), // Text - getChromaColor(t.Text()), // Other - getChromaColor(t.Error()), // Error - - getChromaColor(t.SyntaxKeyword()), // Keyword - getChromaColor(t.SyntaxKeyword()), // KeywordConstant - getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration - getChromaColor(t.SyntaxKeyword()), // KeywordNamespace - getChromaColor(t.SyntaxKeyword()), // KeywordPseudo - getChromaColor(t.SyntaxKeyword()), // KeywordReserved - getChromaColor(t.SyntaxType()), // KeywordType - - getChromaColor(t.Text()), // Name - getChromaColor(t.SyntaxVariable()), // NameAttribute - getChromaColor(t.SyntaxType()), // NameBuiltin - getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo - getChromaColor(t.SyntaxType()), // NameClass - getChromaColor(t.SyntaxVariable()), // NameConstant - getChromaColor(t.SyntaxFunction()), // NameDecorator - getChromaColor(t.SyntaxVariable()), // NameEntity - getChromaColor(t.SyntaxType()), // NameException - getChromaColor(t.SyntaxFunction()), // NameFunction - getChromaColor(t.Text()), // NameLabel - getChromaColor(t.SyntaxType()), // NameNamespace - getChromaColor(t.SyntaxVariable()), // NameOther - getChromaColor(t.SyntaxKeyword()), // NameTag - getChromaColor(t.SyntaxVariable()), // NameVariable - getChromaColor(t.SyntaxVariable()), // NameVariableClass - getChromaColor(t.SyntaxVariable()), // NameVariableGlobal - getChromaColor(t.SyntaxVariable()), // NameVariableInstance - - getChromaColor(t.SyntaxString()), // Literal - getChromaColor(t.SyntaxString()), // LiteralDate - getChromaColor(t.SyntaxString()), // LiteralString - getChromaColor(t.SyntaxString()), // LiteralStringBacktick - getChromaColor(t.SyntaxString()), // LiteralStringChar - getChromaColor(t.SyntaxString()), // LiteralStringDoc - getChromaColor(t.SyntaxString()), // LiteralStringDouble - getChromaColor(t.SyntaxString()), // LiteralStringEscape - getChromaColor(t.SyntaxString()), // LiteralStringHeredoc - getChromaColor(t.SyntaxString()), // LiteralStringInterpol - getChromaColor(t.SyntaxString()), // LiteralStringOther - getChromaColor(t.SyntaxString()), // LiteralStringRegex - getChromaColor(t.SyntaxString()), // LiteralStringSingle - getChromaColor(t.SyntaxString()), // LiteralStringSymbol - - getChromaColor(t.SyntaxNumber()), // LiteralNumber - getChromaColor(t.SyntaxNumber()), // LiteralNumberBin - getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat - getChromaColor(t.SyntaxNumber()), // LiteralNumberHex - getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger - getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong - getChromaColor(t.SyntaxNumber()), // LiteralNumberOct - - getChromaColor(t.SyntaxOperator()), // Operator - getChromaColor(t.SyntaxKeyword()), // OperatorWord - getChromaColor(t.SyntaxPunctuation()), // Punctuation - - getChromaColor(t.SyntaxComment()), // Comment - getChromaColor(t.SyntaxComment()), // CommentHashbang - getChromaColor(t.SyntaxComment()), // CommentMultiline - getChromaColor(t.SyntaxComment()), // CommentSingle - getChromaColor(t.SyntaxComment()), // CommentSpecial - getChromaColor(t.SyntaxKeyword()), // CommentPreproc - - getChromaColor(t.Text()), // Generic - getChromaColor(t.Error()), // GenericDeleted - getChromaColor(t.Text()), // GenericEmph - getChromaColor(t.Error()), // GenericError - getChromaColor(t.Text()), // GenericHeading - getChromaColor(t.Success()), // GenericInserted - getChromaColor(t.TextMuted()), // GenericOutput - getChromaColor(t.Text()), // GenericPrompt - getChromaColor(t.Text()), // GenericStrong - getChromaColor(t.Text()), // GenericSubheading - getChromaColor(t.Error()), // GenericTraceback - getChromaColor(t.Text()), // TextWhitespace - ) - - r := strings.NewReader(syntaxThemeXml) - style := chroma.MustNewXMLStyle(r) - - // Modify the style to use the provided background - s, err := style.Builder().Transform( - func(t chroma.StyleEntry) chroma.StyleEntry { - if _, ok := bg.(lipgloss.NoColor); ok { - return t - } - r, g, b, _ := bg.RGBA() - t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) - return t - }, - ).Build() - if err != nil { - s = styles.Fallback - } - - // Tokenize and format - it, err := l.Tokenise(nil, source) - if err != nil { - return err - } - - return f.Format(w, s, it) -} - -// getColor returns the appropriate hex color string based on terminal background -func getColor(adaptiveColor compat.AdaptiveColor) *string { - return stylesi.AdaptiveColorToString(adaptiveColor) -} - -func getChromaColor(adaptiveColor compat.AdaptiveColor) string { - color := stylesi.AdaptiveColorToString(adaptiveColor) - if color == nil { - return "" - } - return *color -} - -// highlightLine applies syntax highlighting to a single line -func highlightLine(fileName string, line string, bg color.Color) string { - var buf bytes.Buffer - err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg) - if err != nil { - return line - } - return buf.String() -} - -// createStyles generates the lipgloss styles needed for rendering diffs -func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) { - removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg()) - addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg()) - contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg()) - lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber()) - return -} - -// ------------------------------------------------------------------------- -// Rendering Functions -// ------------------------------------------------------------------------- - -// applyHighlighting applies intra-line highlighting to a piece of text -func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string { - // Find all ANSI sequences in the content - ansiMatches := ansiRegex.FindAllStringIndex(content, -1) - - // Build a mapping of visible character positions to their actual indices - visibleIdx := 0 - ansiSequences := make(map[int]string) - lastAnsiSeq := "\x1b[0m" // Default reset sequence - - for i := 0; i < len(content); { - isAnsi := false - for _, match := range ansiMatches { - if match[0] == i { - ansiSequences[visibleIdx] = content[match[0]:match[1]] - lastAnsiSeq = content[match[0]:match[1]] - i = match[1] - isAnsi = true - break - } - } - if isAnsi { - continue - } - - // For non-ANSI positions, store the last ANSI sequence - if _, exists := ansiSequences[visibleIdx]; !exists { - ansiSequences[visibleIdx] = lastAnsiSeq - } - visibleIdx++ - - // Properly advance by UTF-8 rune, not byte - _, size := utf8.DecodeRuneInString(content[i:]) - i += size - } - - // Apply highlighting - var sb strings.Builder - inSelection := false - currentPos := 0 - - // Get the appropriate color based on terminal background - bg := getColor(highlightBg) - fg := getColor(theme.CurrentTheme().BackgroundPanel()) - var bgColor color.Color - var fgColor color.Color - - if bg != nil { - bgColor = lipgloss.Color(*bg) - } - if fg != nil { - fgColor = lipgloss.Color(*fg) - } - for i := 0; i < len(content); { - // Check if we're at an ANSI sequence - isAnsi := false - for _, match := range ansiMatches { - if match[0] == i { - sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence - i = match[1] - isAnsi = true - break - } - } - if isAnsi { - continue - } - - // Check for segment boundaries - for _, seg := range segments { - if seg.Type == segmentType { - if currentPos == seg.Start { - inSelection = true - } - if currentPos == seg.End { - inSelection = false - } - } - } - - // Get current character (properly handle UTF-8) - r, size := utf8.DecodeRuneInString(content[i:]) - char := string(r) - - if inSelection { - // Get the current styling - currentStyle := ansiSequences[currentPos] - - // Apply foreground and background highlight - if fgColor != nil { - sb.WriteString("\x1b[38;2;") - r, g, b, _ := fgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) - } else { - sb.WriteString("\x1b[49m") - } - if bgColor != nil { - sb.WriteString("\x1b[48;2;") - r, g, b, _ := bgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) - } else { - sb.WriteString("\x1b[39m") - } - sb.WriteString(char) - - // Full reset of all attributes to ensure clean state - sb.WriteString("\x1b[0m") - - // Reapply the original ANSI sequence - sb.WriteString(currentStyle) - } else { - // Not in selection, just copy the character - sb.WriteString(char) - } - - currentPos++ - i += size - } - - return sb.String() -} - -// renderLinePrefix renders the line number and marker prefix for a diff line -func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string { - // Style the marker based on line type - var styledMarker string - switch dl.Kind { - case LineRemoved: - styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker) - case LineAdded: - styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker) - case LineContext: - styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker) - default: - styledMarker = marker - } - - return lineNumberStyle.Render(lineNum + " " + styledMarker) -} - -// renderLineContent renders the content of a diff line with syntax and intra-line highlighting -func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string { - // Apply syntax highlighting - content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) - - // Apply intra-line highlighting if needed - if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) { - content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor) - } - - // Add a padding space for added/removed lines - if dl.Kind == LineRemoved || dl.Kind == LineAdded { - content = bgStyle.Render(" ") + content - } - - // Create the final line and truncate if needed - return bgStyle.MaxHeight(1).Width(width).Render( - ansi.Truncate( - content, - width, - "...", - ), - ) -} - -// renderUnifiedLine renders a single line in unified diff format -func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string { - removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t) - - // Determine line style and marker based on line type - var marker string - var bgStyle stylesi.Style - var lineNum string - var highlightColor compat.AdaptiveColor - - switch dl.Kind { - case LineRemoved: - marker = "-" - bgStyle = removedLineStyle - lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved()) - highlightColor = t.DiffHighlightRemoved() // TODO: handle "none" - if dl.OldLineNo > 0 { - lineNum = fmt.Sprintf("%6d ", dl.OldLineNo) - } else { - lineNum = " " - } - case LineAdded: - marker = "+" - bgStyle = addedLineStyle - lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded()) - highlightColor = t.DiffHighlightAdded() // TODO: handle "none" - if dl.NewLineNo > 0 { - lineNum = fmt.Sprintf(" %7d", dl.NewLineNo) - } else { - lineNum = " " - } - case LineContext: - marker = " " - bgStyle = contextLineStyle - if dl.OldLineNo > 0 && dl.NewLineNo > 0 { - lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo) - } else { - lineNum = " " - } - } - - // Create the line prefix - prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t) - - // Render the content - prefixWidth := ansi.StringWidth(prefix) - contentWidth := width - prefixWidth - content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth) - - return prefix + content -} - -// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns -func renderDiffColumnLine( - fileName string, - dl *DiffLine, - colWidth int, - isLeftColumn bool, - t theme.Theme, -) string { - if dl == nil { - contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg()) - return contextLineStyle.Width(colWidth).Render("") - } - - removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t) - - // Determine line style based on line type and column - var marker string - var bgStyle stylesi.Style - var lineNum string - var highlightColor compat.AdaptiveColor - - if isLeftColumn { - // Left column logic - switch dl.Kind { - case LineRemoved: - marker = "-" - bgStyle = removedLineStyle - lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved()) - highlightColor = t.DiffHighlightRemoved() // TODO: handle "none" - case LineAdded: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = " " - bgStyle = contextLineStyle - } - - // Format line number for left column - if dl.OldLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.OldLineNo) - } - } else { - // Right column logic - switch dl.Kind { - case LineAdded: - marker = "+" - bgStyle = addedLineStyle - lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded()) - highlightColor = t.DiffHighlightAdded() - case LineRemoved: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = " " - bgStyle = contextLineStyle - } - - // Format line number for right column - if dl.NewLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.NewLineNo) - } - } - - // Create the line prefix - prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t) - - // Determine if we should render content - shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) || - (dl.Kind == LineAdded && !isLeftColumn) || - dl.Kind == LineContext - - if !shouldRenderContent { - return bgStyle.Width(colWidth).Render("") - } - - // Render the content - prefixWidth := ansi.StringWidth(prefix) - contentWidth := colWidth - prefixWidth - content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth) - - return prefix + content -} - -// renderLeftColumn formats the left side of a side-by-side diff -func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { - return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme()) -} - -// renderRightColumn formats the right side of a side-by-side diff -func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { - return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme()) -} - -// ------------------------------------------------------------------------- -// Public API -// ------------------------------------------------------------------------- - -// RenderUnifiedHunk formats a hunk for unified display -func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string { - // Apply options to create the configuration - config := NewUnifiedConfig(opts...) - - // Make a copy of the hunk so we don't modify the original - hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))} - copy(hunkCopy.Lines, h.Lines) - - // Highlight changes within lines - HighlightIntralineChanges(&hunkCopy) - - var sb strings.Builder - sb.Grow(len(hunkCopy.Lines) * config.Width) - - util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string { - return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n" - }) - - return sb.String() -} - -// RenderSideBySideHunk formats a hunk for side-by-side display -func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string { - // Apply options to create the configuration - config := NewSideBySideConfig(opts...) - - // Make a copy of the hunk so we don't modify the original - hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))} - copy(hunkCopy.Lines, h.Lines) - - // Highlight changes within lines - HighlightIntralineChanges(&hunkCopy) - - // Pair lines for side-by-side display - pairs := pairLines(hunkCopy.Lines) - - // Calculate column width - colWidth := config.Width / 2 - - leftWidth := colWidth - rightWidth := config.Width - colWidth - var sb strings.Builder - - util.WriteStringsPar(&sb, pairs, func(p linePair) string { - wg := &sync.WaitGroup{} - var leftStr, rightStr string - wg.Add(2) - go func() { - defer wg.Done() - leftStr = renderLeftColumn(fileName, p.left, leftWidth) - }() - go func() { - defer wg.Done() - rightStr = renderRightColumn(fileName, p.right, rightWidth) - }() - wg.Wait() - return leftStr + rightStr + "\n" - }) - - return sb.String() -} - -// FormatUnifiedDiff creates a unified formatted view of a diff -func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) { - diffResult, err := ParseUnifiedDiff(diffText) - if err != nil { - return "", err - } - - var sb strings.Builder - util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string { - return RenderUnifiedHunk(filename, h, opts...) - }) - - return sb.String(), nil -} - -// FormatDiff creates a side-by-side formatted view of a diff -func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) { - diffResult, err := ParseUnifiedDiff(diffText) - if err != nil { - return "", err - } - - var sb strings.Builder - util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string { - return RenderSideBySideHunk(filename, h, opts...) - }) - - return sb.String(), nil -} diff --git a/packages/tui/internal/components/diff/parse.go b/packages/tui/internal/components/diff/parse.go deleted file mode 100644 index 261ba5970..000000000 --- a/packages/tui/internal/components/diff/parse.go +++ /dev/null @@ -1,58 +0,0 @@ -package diff - -import ( - "bufio" - "fmt" - "strings" -) - -type DiffStats struct { - Added int - Removed int - Modified int -} - -func ParseStats(diff string) (map[string]DiffStats, error) { - stats := make(map[string]DiffStats) - var currentFile string - scanner := bufio.NewScanner(strings.NewReader(diff)) - - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "---") { - continue - } else if strings.HasPrefix(line, "+++") { - parts := strings.SplitN(line, " ", 2) - if len(parts) == 2 { - currentFile = strings.TrimPrefix(parts[1], "b/") - } - continue - } - if strings.HasPrefix(line, "@@") { - continue - } - if currentFile == "" { - continue - } - - fileStats := stats[currentFile] - switch { - case strings.HasPrefix(line, "+"): - fileStats.Added++ - case strings.HasPrefix(line, "-"): - fileStats.Removed++ - } - stats[currentFile] = fileStats - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading diff string: %w", err) - } - - for file, fileStats := range stats { - fileStats.Modified = fileStats.Added + fileStats.Removed - stats[file] = fileStats - } - - return stats, nil -} diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go deleted file mode 100644 index a9823d0ab..000000000 --- a/packages/tui/internal/components/list/list.go +++ /dev/null @@ -1,436 +0,0 @@ -package list - -import ( - "strings" - - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/muesli/reflow/truncate" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" -) - -// Item interface that all list items must implement -type Item interface { - Render(selected bool, width int, baseStyle styles.Style) string - Selectable() bool -} - -// RenderFunc defines how to render an item in the list -type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string - -// SelectableFunc defines whether an item is selectable -type SelectableFunc[T any] func(item T) bool - -// Options holds configuration for the list component -type Options[T any] struct { - items []T - maxVisibleHeight int - fallbackMsg string - useAlphaNumericKeys bool - renderItem RenderFunc[T] - isSelectable SelectableFunc[T] - baseStyle styles.Style -} - -// Option is a function that configures the list component -type Option[T any] func(*Options[T]) - -// WithItems sets the initial items for the list -func WithItems[T any](items []T) Option[T] { - return func(o *Options[T]) { - o.items = items - } -} - -// WithMaxVisibleHeight sets the maximum visible height in lines -func WithMaxVisibleHeight[T any](height int) Option[T] { - return func(o *Options[T]) { - o.maxVisibleHeight = height - } -} - -// WithFallbackMessage sets the message to show when the list is empty -func WithFallbackMessage[T any](msg string) Option[T] { - return func(o *Options[T]) { - o.fallbackMsg = msg - } -} - -// WithAlphaNumericKeys enables j/k navigation keys -func WithAlphaNumericKeys[T any](enabled bool) Option[T] { - return func(o *Options[T]) { - o.useAlphaNumericKeys = enabled - } -} - -// WithRenderFunc sets the function to render items -func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] { - return func(o *Options[T]) { - o.renderItem = fn - } -} - -// WithSelectableFunc sets the function to determine if items are selectable -func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] { - return func(o *Options[T]) { - o.isSelectable = fn - } -} - -// WithStyle sets the base style that gets passed to render functions -func WithStyle[T any](style styles.Style) Option[T] { - return func(o *Options[T]) { - o.baseStyle = style - } -} - -type List[T any] interface { - tea.Model - tea.ViewModel - SetMaxWidth(maxWidth int) - GetSelectedItem() (item T, idx int) - SetItems(items []T) - GetItems() []T - SetSelectedIndex(idx int) - SetEmptyMessage(msg string) - IsEmpty() bool - GetMaxVisibleHeight() int -} - -type listComponent[T any] struct { - fallbackMsg string - items []T - selectedIdx int - maxWidth int - maxVisibleHeight int - useAlphaNumericKeys bool - width int - height int - renderItem RenderFunc[T] - isSelectable SelectableFunc[T] - baseStyle styles.Style -} - -type listKeyMap struct { - Up key.Binding - Down key.Binding - UpAlpha key.Binding - DownAlpha key.Binding -} - -var simpleListKeys = listKeyMap{ - Up: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous list item"), - ), - Down: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next list item"), - ), - UpAlpha: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous list item"), - ), - DownAlpha: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next list item"), - ), -} - -func (c *listComponent[T]) Init() tea.Cmd { - return nil -} - -func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)): - c.moveUp() - return c, nil - case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)): - c.moveDown() - return c, nil - } - } - - return c, nil -} - -// moveUp moves the selection up, skipping non-selectable items -func (c *listComponent[T]) moveUp() { - if len(c.items) == 0 { - return - } - - // Find the previous selectable item - for i := c.selectedIdx - 1; i >= 0; i-- { - if c.isSelectable(c.items[i]) { - c.selectedIdx = i - return - } - } - - // If no selectable item found above, wrap to the bottom - for i := len(c.items) - 1; i > c.selectedIdx; i-- { - if c.isSelectable(c.items[i]) { - c.selectedIdx = i - return - } - } -} - -// moveDown moves the selection down, skipping non-selectable items -func (c *listComponent[T]) moveDown() { - if len(c.items) == 0 { - return - } - - originalIdx := c.selectedIdx - // First try moving down from current position - for i := c.selectedIdx + 1; i < len(c.items); i++ { - if c.isSelectable(c.items[i]) { - c.selectedIdx = i - return - } - } - - // If no selectable item found below, wrap to the top - for i := 0; i < originalIdx; i++ { - if c.isSelectable(c.items[i]) { - c.selectedIdx = i - return - } - } -} - -func (c *listComponent[T]) GetSelectedItem() (T, int) { - if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) { - return c.items[c.selectedIdx], c.selectedIdx - } - - var zero T - return zero, -1 -} - -func (c *listComponent[T]) SetItems(items []T) { - c.items = items - c.selectedIdx = 0 - - // Ensure initial selection is on a selectable item - if len(items) > 0 && !c.isSelectable(items[0]) { - c.moveDown() - } -} - -func (c *listComponent[T]) GetItems() []T { - return c.items -} - -func (c *listComponent[T]) SetEmptyMessage(msg string) { - c.fallbackMsg = msg -} - -func (c *listComponent[T]) IsEmpty() bool { - return len(c.items) == 0 -} - -func (c *listComponent[T]) SetMaxWidth(width int) { - c.maxWidth = width -} - -func (c *listComponent[T]) SetSelectedIndex(idx int) { - if idx >= 0 && idx < len(c.items) { - c.selectedIdx = idx - } -} - -func (c *listComponent[T]) GetMaxVisibleHeight() int { - return c.maxVisibleHeight -} - -func (c *listComponent[T]) View() string { - items := c.items - maxWidth := c.maxWidth - if maxWidth == 0 { - maxWidth = 80 // Default width if not set - } - - if len(items) <= 0 { - return c.fallbackMsg - } - - // Calculate viewport based on actual heights - startIdx, endIdx := c.calculateViewport() - - listItems := make([]string, 0, endIdx-startIdx) - - for i := startIdx; i < endIdx; i++ { - item := items[i] - - // Special handling for HeaderItem to remove top margin on first item - if i == startIdx { - // Check if this is a HeaderItem - if _, ok := any(item).(Item); ok { - if headerItem, isHeader := any(item).(HeaderItem); isHeader { - // Render header without top margin when it's first - t := theme.CurrentTheme() - truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...") - headerStyle := c.baseStyle. - Foreground(t.Accent()). - Bold(true). - MarginBottom(0). - PaddingLeft(1) - listItems = append(listItems, headerStyle.Render(truncatedStr)) - continue - } - } - } - - title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle) - listItems = append(listItems, title) - } - - return strings.Join(listItems, "\n") -} - -// calculateViewport determines which items to show based on available space -func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) { - items := c.items - if len(items) == 0 { - return 0, 0 - } - - // Calculate heights of all items - itemHeights := make([]int, len(items)) - for i, item := range items { - rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle) - itemHeights[i] = lipgloss.Height(rendered) - } - - // Find the range of items that fit within maxVisibleHeight - // Start by trying to center the selected item - start := 0 - end := len(items) - - // Calculate height from start to selected - heightToSelected := 0 - for i := 0; i <= c.selectedIdx && i < len(items); i++ { - heightToSelected += itemHeights[i] - } - - // If selected item is beyond visible height, scroll to show it - if heightToSelected > c.maxVisibleHeight { - // Start from selected and work backwards to find start - currentHeight := itemHeights[c.selectedIdx] - start = c.selectedIdx - - for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- { - currentHeight += itemHeights[i] - start = i - } - } - - // Calculate end based on start - currentHeight := 0 - for i := start; i < len(items); i++ { - if currentHeight+itemHeights[i] > c.maxVisibleHeight { - end = i - break - } - currentHeight += itemHeights[i] - } - - return start, end -} - -func abs(x int) int { - if x < 0 { - return -x - } - return x -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func NewListComponent[T any](opts ...Option[T]) List[T] { - options := &Options[T]{ - baseStyle: styles.NewStyle(), // Default empty style - } - - for _, opt := range opts { - opt(options) - } - - return &listComponent[T]{ - fallbackMsg: options.fallbackMsg, - items: options.items, - maxVisibleHeight: options.maxVisibleHeight, - useAlphaNumericKeys: options.useAlphaNumericKeys, - selectedIdx: 0, - renderItem: options.renderItem, - isSelectable: options.isSelectable, - baseStyle: options.baseStyle, - } -} - -// StringItem is a simple implementation of Item for string values -type StringItem string - -func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string { - t := theme.CurrentTheme() - - truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...") - - var itemStyle styles.Style - if selected { - itemStyle = baseStyle. - Background(t.Primary()). - Foreground(t.BackgroundElement()). - Width(width). - PaddingLeft(1) - } else { - itemStyle = baseStyle. - Foreground(t.TextMuted()). - PaddingLeft(1) - } - - return itemStyle.Render(truncatedStr) -} - -func (s StringItem) Selectable() bool { - return true -} - -// HeaderItem is a non-selectable header item for grouping -type HeaderItem string - -func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string { - t := theme.CurrentTheme() - - truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...") - - headerStyle := baseStyle. - Foreground(t.Accent()). - Bold(true). - MarginTop(1). - MarginBottom(0). - PaddingLeft(1) - - return headerStyle.Render(truncatedStr) -} - -func (h HeaderItem) Selectable() bool { - return false -} - -// Ensure StringItem and HeaderItem implement Item -var _ Item = StringItem("") -var _ Item = HeaderItem("") diff --git a/packages/tui/internal/components/list/list_test.go b/packages/tui/internal/components/list/list_test.go deleted file mode 100644 index 25cca8cf4..000000000 --- a/packages/tui/internal/components/list/list_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package list - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/sst/opencode/internal/styles" -) - -// testItem is a simple test implementation of ListItem -type testItem struct { - value string -} - -func (t testItem) Render( - selected bool, - width int, - isFirstInViewport bool, - baseStyle styles.Style, -) string { - return t.value -} - -func (t testItem) Selectable() bool { - return true -} - -// createTestList creates a list with test items for testing -func createTestList() *listComponent[testItem] { - items := []testItem{ - {value: "item1"}, - {value: "item2"}, - {value: "item3"}, - } - list := NewListComponent( - WithItems(items), - WithMaxVisibleHeight[testItem](5), - WithFallbackMessage[testItem]("empty"), - WithAlphaNumericKeys[testItem](false), - WithRenderFunc( - func(item testItem, selected bool, width int, baseStyle styles.Style) string { - return item.Render(selected, width, false, baseStyle) - }, - ), - WithSelectableFunc(func(item testItem) bool { - return item.Selectable() - }), - ) - - return list.(*listComponent[testItem]) -} - -func TestArrowKeyNavigation(t *testing.T) { - list := createTestList() - - // Test down arrow navigation - downKey := tea.KeyPressMsg{Code: tea.KeyDown} - updatedModel, _ := list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - _, idx := list.GetSelectedItem() - if idx != 1 { - t.Errorf("Expected selected index 1 after down arrow, got %d", idx) - } - - // Test up arrow navigation - upKey := tea.KeyPressMsg{Code: tea.KeyUp} - updatedModel, _ = list.Update(upKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected selected index 0 after up arrow, got %d", idx) - } -} - -func TestJKKeyNavigation(t *testing.T) { - items := []testItem{ - {value: "item1"}, - {value: "item2"}, - {value: "item3"}, - } - // Create list with alpha keys enabled - list := NewListComponent( - WithItems(items), - WithMaxVisibleHeight[testItem](5), - WithFallbackMessage[testItem]("empty"), - WithAlphaNumericKeys[testItem](true), - WithRenderFunc( - func(item testItem, selected bool, width int, baseStyle styles.Style) string { - return item.Render(selected, width, false, baseStyle) - }, - ), - WithSelectableFunc(func(item testItem) bool { - return item.Selectable() - }), - ) - - // Test j key (down) - jKey := tea.KeyPressMsg{Code: 'j', Text: "j"} - updatedModel, _ := list.Update(jKey) - list = updatedModel.(*listComponent[testItem]) - _, idx := list.GetSelectedItem() - if idx != 1 { - t.Errorf("Expected selected index 1 after 'j' key, got %d", idx) - } - - // Test k key (up) - kKey := tea.KeyPressMsg{Code: 'k', Text: "k"} - updatedModel, _ = list.Update(kKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected selected index 0 after 'k' key, got %d", idx) - } -} - -func TestCtrlNavigation(t *testing.T) { - list := createTestList() - - // Test Ctrl-N (down) - ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl} - updatedModel, _ := list.Update(ctrlN) - list = updatedModel.(*listComponent[testItem]) - _, idx := list.GetSelectedItem() - if idx != 1 { - t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx) - } - - // Test Ctrl-P (up) - ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl} - updatedModel, _ = list.Update(ctrlP) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx) - } -} - -func TestNavigationBoundaries(t *testing.T) { - list := createTestList() - - // Test up arrow at first item (should wrap to last item) - upKey := tea.KeyPressMsg{Code: tea.KeyUp} - updatedModel, _ := list.Update(upKey) - list = updatedModel.(*listComponent[testItem]) - _, idx := list.GetSelectedItem() - if idx != 2 { - t.Errorf("Expected to wrap to index 2 when pressing up at first item, got %d", idx) - } - - // Move to first item - list.SetSelectedIndex(0) - - // Move to last item - downKey := tea.KeyPressMsg{Code: tea.KeyDown} - updatedModel, _ = list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - updatedModel, _ = list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 2 { - t.Errorf("Expected to be at index 2, got %d", idx) - } - - // Test down arrow at last item (should wrap to first item) - updatedModel, _ = list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected to wrap to index 0 when pressing down at last item, got %d", idx) - } -} - -func TestEmptyList(t *testing.T) { - emptyList := NewListComponent( - WithItems([]testItem{}), - WithMaxVisibleHeight[testItem](5), - WithFallbackMessage[testItem]("empty"), - WithAlphaNumericKeys[testItem](false), - WithRenderFunc( - func(item testItem, selected bool, width int, baseStyle styles.Style) string { - return item.Render(selected, width, false, baseStyle) - }, - ), - WithSelectableFunc(func(item testItem) bool { - return item.Selectable() - }), - ) - - // Test navigation on empty list (should not crash) - downKey := tea.KeyPressMsg{Code: tea.KeyDown} - upKey := tea.KeyPressMsg{Code: tea.KeyUp} - ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl} - ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl} - - updatedModel, _ := emptyList.Update(downKey) - emptyList = updatedModel.(*listComponent[testItem]) - updatedModel, _ = emptyList.Update(upKey) - emptyList = updatedModel.(*listComponent[testItem]) - updatedModel, _ = emptyList.Update(ctrlN) - emptyList = updatedModel.(*listComponent[testItem]) - updatedModel, _ = emptyList.Update(ctrlP) - emptyList = updatedModel.(*listComponent[testItem]) - - // Verify empty list behavior - _, idx := emptyList.GetSelectedItem() - if idx != -1 { - t.Errorf("Expected index -1 for empty list, got %d", idx) - } - - if !emptyList.IsEmpty() { - t.Error("Expected IsEmpty() to return true for empty list") - } -} - -func TestWrapAroundNavigation(t *testing.T) { - list := createTestList() - - // Start at first item (index 0) - _, idx := list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected to start at index 0, got %d", idx) - } - - // Press up arrow - should wrap to last item (index 2) - upKey := tea.KeyPressMsg{Code: tea.KeyUp} - updatedModel, _ := list.Update(upKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 2 { - t.Errorf("Expected to wrap to index 2 when pressing up from first item, got %d", idx) - } - - // Press down arrow - should wrap to first item (index 0) - downKey := tea.KeyPressMsg{Code: tea.KeyDown} - updatedModel, _ = list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 0 { - t.Errorf("Expected to wrap to index 0 when pressing down from last item, got %d", idx) - } - - // Navigate to middle and verify normal navigation still works - updatedModel, _ = list.Update(downKey) - list = updatedModel.(*listComponent[testItem]) - _, idx = list.GetSelectedItem() - if idx != 1 { - t.Errorf("Expected to move to index 1, got %d", idx) - } -} diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go deleted file mode 100644 index 09989d8ec..000000000 --- a/packages/tui/internal/components/modal/modal.go +++ /dev/null @@ -1,145 +0,0 @@ -package modal - -import ( - "strings" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" -) - -// CloseModalMsg is a message to signal that the active modal should be closed. -type CloseModalMsg struct{} - -// Modal is a reusable modal component that handles frame rendering and overlay placement -type Modal struct { - width int - height int - title string - maxWidth int - maxHeight int - fitContent bool -} - -// ModalOption is a function that configures a Modal -type ModalOption func(*Modal) - -// WithTitle sets the modal title -func WithTitle(title string) ModalOption { - return func(m *Modal) { - m.title = title - } -} - -// WithMaxWidth sets the maximum width -func WithMaxWidth(width int) ModalOption { - return func(m *Modal) { - m.maxWidth = width - m.fitContent = false - } -} - -// WithMaxHeight sets the maximum height -func WithMaxHeight(height int) ModalOption { - return func(m *Modal) { - m.maxHeight = height - } -} - -func WithFitContent(fit bool) ModalOption { - return func(m *Modal) { - m.fitContent = fit - } -} - -// New creates a new Modal with the given options -func New(opts ...ModalOption) *Modal { - m := &Modal{ - maxWidth: 0, - maxHeight: 0, - fitContent: true, - } - - for _, opt := range opts { - opt(m) - } - - return m -} - -func (m *Modal) SetTitle(title string) { - m.title = title -} - -// Render renders the modal centered on the screen -func (m *Modal) Render(contentView string, background string) string { - t := theme.CurrentTheme() - - outerWidth := layout.Current.Container.Width - 8 - if m.maxWidth > 0 && outerWidth > m.maxWidth { - outerWidth = m.maxWidth - } - - if m.fitContent { - titleWidth := lipgloss.Width(m.title) - contentWidth := lipgloss.Width(contentView) - largestWidth := max(titleWidth+2, contentWidth) - outerWidth = largestWidth + 6 - } - - innerWidth := outerWidth - 4 - - baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()) - - var finalContent string - if m.title != "" { - titleStyle := baseStyle. - Foreground(t.Text()). - Bold(true). - Padding(0, 1) - - escStyle := baseStyle.Foreground(t.TextMuted()) - escText := escStyle.Render("esc") - - // Calculate position for esc text - titleWidth := lipgloss.Width(m.title) - escWidth := lipgloss.Width(escText) - spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2) - spacer := strings.Repeat(" ", spacesNeeded) - titleLine := m.title + spacer + escText - titleLine = titleStyle.Render(titleLine) - - finalContent = strings.Join([]string{titleLine, "", contentView}, "\n") - } else { - finalContent = contentView - } - - modalStyle := baseStyle. - PaddingTop(1). - PaddingBottom(1). - PaddingLeft(2). - PaddingRight(2) - - modalView := modalStyle. - Width(outerWidth). - Render(finalContent) - - // Calculate position for centering - bgHeight := lipgloss.Height(background) - bgWidth := lipgloss.Width(background) - modalHeight := lipgloss.Height(modalView) - modalWidth := lipgloss.Width(modalView) - - row := (bgHeight - modalHeight) / 2 - col := (bgWidth - modalWidth) / 2 - - return layout.PlaceOverlay( - col-1, // TODO: whyyyyy - row, - modalView, - background, - layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.BorderActive()), - ) -} diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go deleted file mode 100644 index 233bcf524..000000000 --- a/packages/tui/internal/components/qr/qr.go +++ /dev/null @@ -1,56 +0,0 @@ -package qr - -import ( - "strings" - - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "rsc.io/qr" -) - -var tops_bottoms = []rune{' ', '▀', '▄', '█'} - -// Generate a text string to a QR code, which you can write to a terminal or file. -func Generate(text string) (string, int, error) { - code, err := qr.Encode(text, qr.Level(0)) - if err != nil { - return "", 0, err - } - - t := theme.CurrentTheme() - if t == nil { - return "", 0, err - } - - // Create lipgloss style for QR code with theme colors - qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background()) - - var result strings.Builder - - // content - for y := 0; y < code.Size-1; y += 2 { - var line strings.Builder - for x := 0; x < code.Size; x += 1 { - var num int8 - if code.Black(x, y) { - num += 1 - } - if code.Black(x, y+1) { - num += 2 - } - line.WriteRune(tops_bottoms[num]) - } - result.WriteString(qrStyle.Render(line.String()) + "\n") - } - - // add lower border when required (only required when QR size is odd) - if code.Size%2 == 1 { - var borderLine strings.Builder - for range code.Size { - borderLine.WriteRune('▀') - } - result.WriteString(qrStyle.Render(borderLine.String()) + "\n") - } - - return result.String(), code.Size, nil -} diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go deleted file mode 100644 index aba80900b..000000000 --- a/packages/tui/internal/components/status/status.go +++ /dev/null @@ -1,340 +0,0 @@ -package status - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/fsnotify/fsnotify" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -type GitBranchUpdatedMsg struct { - Branch string -} - -type StatusComponent interface { - tea.Model - tea.ViewModel - Cleanup() -} - -type statusComponent struct { - app *app.App - width int - cwd string - branch string - watcher *fsnotify.Watcher - done chan struct{} - lastUpdate time.Time -} - -func (m *statusComponent) Init() tea.Cmd { - return m.startGitWatcher() -} - -func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - case GitBranchUpdatedMsg: - if m.branch != msg.Branch { - m.branch = msg.Branch - } - // Continue watching for changes (persistent watcher) - return m, m.watchForGitChanges() - } - return m, nil -} - -func (m *statusComponent) logo() string { - t := theme.CurrentTheme() - base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render - emphasis := styles.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundElement()). - Bold(true). - Render - - open := base("open") - code := emphasis("code") - version := base(" " + m.app.Version) - - content := open + code - if m.width > 40 { - content += version - } - return styles.NewStyle(). - Background(t.BackgroundElement()). - Padding(0, 1). - Render(content) -} - -func (m *statusComponent) collapsePath(path string, maxWidth int) string { - if lipgloss.Width(path) <= maxWidth { - return path - } - - const ellipsis = ".." - ellipsisLen := len(ellipsis) - - if maxWidth <= ellipsisLen { - if maxWidth > 0 { - return "..."[:maxWidth] - } - return "" - } - - separator := string(filepath.Separator) - parts := strings.Split(path, separator) - - if len(parts) == 1 { - return path[:maxWidth-ellipsisLen] + ellipsis - } - - truncatedPath := parts[len(parts)-1] - for i := len(parts) - 2; i >= 0; i-- { - part := parts[i] - if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth { - return ellipsis + separator + truncatedPath - } - truncatedPath = part + separator + truncatedPath - } - return truncatedPath -} - -func (m *statusComponent) View() string { - t := theme.CurrentTheme() - logo := m.logo() - logoWidth := lipgloss.Width(logo) - - var modeBackground compat.AdaptiveColor - var modeForeground compat.AdaptiveColor - - agentColor := util.GetAgentColor(m.app.AgentIndex) - - if m.app.AgentIndex == 0 { - modeBackground = t.BackgroundElement() - modeForeground = agentColor - } else { - modeBackground = agentColor - modeForeground = t.BackgroundPanel() - } - - command := m.app.Commands[commands.AgentCycleCommand] - kb := command.Keybindings[0] - key := kb.Key - if kb.RequiresLeader { - key = m.app.Config.Keybinds.Leader + " " + kb.Key - } - - agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground) - agentNameStyle := agentStyle.Bold(true).Render - agentDescStyle := agentStyle.Render - agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT") - agent = agentStyle. - Padding(0, 1). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(modeBackground). - BorderBackground(t.BackgroundPanel()). - Render(agent) - - faintStyle := styles.NewStyle(). - Faint(true). - Background(t.BackgroundPanel()). - Foreground(t.TextMuted()) - agent = faintStyle.Render(key+" ") + agent - modeWidth := lipgloss.Width(agent) - - availableWidth := m.width - logoWidth - modeWidth - branchSuffix := "" - if m.branch != "" { - branchSuffix = ":" + m.branch - } - - maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix) - cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth) - - if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) { - cwdDisplay += faintStyle.Render(branchSuffix) - } - - cwd := styles.NewStyle(). - Foreground(t.TextMuted()). - Background(t.BackgroundPanel()). - Padding(0, 1). - Render(cwdDisplay) - - background := t.BackgroundPanel() - status := layout.Render( - layout.FlexOptions{ - Background: &background, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: m.width, - }, - layout.FlexItem{ - View: logo + cwd, - }, - layout.FlexItem{ - View: agent, - }, - ) - - blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("") - return blank + "\n" + status -} - -func (m *statusComponent) startGitWatcher() tea.Cmd { - cmd := util.CmdHandler( - GitBranchUpdatedMsg{Branch: getCurrentGitBranch(util.CwdPath)}, - ) - if err := m.initWatcher(); err != nil { - return cmd - } - return tea.Batch(cmd, m.watchForGitChanges()) -} - -func (m *statusComponent) initWatcher() error { - gitDir := filepath.Join(util.CwdPath, ".git") - headFile := filepath.Join(gitDir, "HEAD") - if info, err := os.Stat(gitDir); err != nil || !info.IsDir() { - return err - } - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - - if err := watcher.Add(headFile); err != nil { - watcher.Close() - return err - } - - // Also watch the ref file if HEAD points to a ref - refFile := getGitRefFile(util.CwdPath) - if refFile != headFile && refFile != "" { - if _, err := os.Stat(refFile); err == nil { - watcher.Add(refFile) // Ignore error, HEAD watching is sufficient - } - } - - m.watcher = watcher - m.done = make(chan struct{}) - return nil -} - -func (m *statusComponent) watchForGitChanges() tea.Cmd { - if m.watcher == nil { - return nil - } - - return tea.Cmd(func() tea.Msg { - for { - select { - case event, ok := <-m.watcher.Events: - branch := getCurrentGitBranch(util.CwdPath) - if !ok { - return GitBranchUpdatedMsg{Branch: branch} - } - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { - // Debounce updates to prevent excessive refreshes - now := time.Now() - if now.Sub(m.lastUpdate) < 100*time.Millisecond { - continue - } - m.lastUpdate = now - if strings.HasSuffix(event.Name, "HEAD") { - m.updateWatchedFiles() - } - return GitBranchUpdatedMsg{Branch: branch} - } - case <-m.watcher.Errors: - // Continue watching even on errors - case <-m.done: - return GitBranchUpdatedMsg{Branch: ""} - } - } - }) -} - -func (m *statusComponent) updateWatchedFiles() { - if m.watcher == nil { - return - } - refFile := getGitRefFile(util.CwdPath) - headFile := filepath.Join(util.CwdPath, ".git", "HEAD") - if refFile != headFile && refFile != "" { - if _, err := os.Stat(refFile); err == nil { - // Try to add the new ref file (ignore error if already watching) - m.watcher.Add(refFile) - } - } -} - -func getCurrentGitBranch(cwd string) string { - cmd := exec.Command("git", "branch", "--show-current") - cmd.Dir = cwd - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} - -func getGitRefFile(cwd string) string { - headFile := filepath.Join(cwd, ".git", "HEAD") - content, err := os.ReadFile(headFile) - if err != nil { - return "" - } - - headContent := strings.TrimSpace(string(content)) - if after, ok := strings.CutPrefix(headContent, "ref: "); ok { - // HEAD points to a ref file - refPath := after - return filepath.Join(cwd, ".git", refPath) - } - - // HEAD contains a direct commit hash - return headFile -} - -func (m *statusComponent) Cleanup() { - if m.done != nil { - close(m.done) - } - if m.watcher != nil { - m.watcher.Close() - } -} - -func NewStatusCmp(app *app.App) StatusComponent { - statusComponent := &statusComponent{ - app: app, - lastUpdate: time.Now(), - } - - homePath, err := os.UserHomeDir() - cwdPath := util.CwdPath - if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) { - cwdPath = "~" + cwdPath[len(homePath):] - } - statusComponent.cwd = cwdPath - - return statusComponent -} diff --git a/packages/tui/internal/components/status/status_test.go b/packages/tui/internal/components/status/status_test.go deleted file mode 100644 index 1e1caf8ac..000000000 --- a/packages/tui/internal/components/status/status_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package status - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestGetCurrentGitBranch(t *testing.T) { - // Test in current directory (should be a git repo) - branch := getCurrentGitBranch(".") - if branch == "" { - t.Skip("Not in a git repository, skipping test") - } - t.Logf("Current branch: %s", branch) -} - -func TestGetGitRefFile(t *testing.T) { - // Create a temporary git directory structure for testing - tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git") - err := os.MkdirAll(gitDir, 0755) - if err != nil { - t.Fatal(err) - } - - // Test case 1: HEAD points to a ref - headFile := filepath.Join(gitDir, "HEAD") - err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644) - if err != nil { - t.Fatal(err) - } - - refFile := getGitRefFile(tmpDir) - expected := filepath.Join(gitDir, "refs", "heads", "main") - if refFile != expected { - t.Errorf("Expected %s, got %s", expected, refFile) - } - - // Test case 2: HEAD contains a direct commit hash - err = os.WriteFile(headFile, []byte("abc123def456\n"), 0644) - if err != nil { - t.Fatal(err) - } - - refFile = getGitRefFile(tmpDir) - if refFile != headFile { - t.Errorf("Expected %s, got %s", headFile, refFile) - } -} - -func TestFileWatcherIntegration(t *testing.T) { - // This test requires being in a git repository - if getCurrentGitBranch(".") == "" { - t.Skip("Not in a git repository, skipping integration test") - } - - // Test that the file watcher setup doesn't crash - tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git") - err := os.MkdirAll(gitDir, 0755) - if err != nil { - t.Fatal(err) - } - - headFile := filepath.Join(gitDir, "HEAD") - err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644) - if err != nil { - t.Fatal(err) - } - - // Create the refs directory and file - refsDir := filepath.Join(gitDir, "refs", "heads") - err = os.MkdirAll(refsDir, 0755) - if err != nil { - t.Fatal(err) - } - - mainRef := filepath.Join(refsDir, "main") - err = os.WriteFile(mainRef, []byte("abc123def456\n"), 0644) - if err != nil { - t.Fatal(err) - } - - // Test that we can create a watcher without crashing - // This is a basic smoke test - done := make(chan bool, 1) - go func() { - time.Sleep(100 * time.Millisecond) - done <- true - }() - - select { - case <-done: - // Test passed - no crash - case <-time.After(1 * time.Second): - t.Error("Test timed out") - } -} diff --git a/packages/tui/internal/components/textarea/memoization.go b/packages/tui/internal/components/textarea/memoization.go deleted file mode 100644 index 2c9aec4f7..000000000 --- a/packages/tui/internal/components/textarea/memoization.go +++ /dev/null @@ -1,125 +0,0 @@ -// Package memoization implement a simple memoization cache. It's designed to -// improve performance in textarea. -package textarea - -import ( - "container/list" - "crypto/sha256" - "fmt" - "sync" -) - -// Hasher is an interface that requires a Hash method. The Hash method is -// expected to return a string representation of the hash of the object. -type Hasher interface { - Hash() string -} - -// entry is a struct that holds a key-value pair. It is used as an element -// in the evictionList of the MemoCache. -type entry[T any] struct { - key string - value T -} - -// MemoCache is a struct that represents a cache with a set capacity. It -// uses an LRU (Least Recently Used) eviction policy. It is safe for -// concurrent use. -type MemoCache[H Hasher, T any] struct { - capacity int - mutex sync.Mutex - cache map[string]*list.Element // The cache holding the results - evictionList *list.List // A list to keep track of the order for LRU - hashableItems map[string]T // This map keeps track of the original hashable items (optional) -} - -// NewMemoCache is a function that creates a new MemoCache with a given -// capacity. It returns a pointer to the created MemoCache. -func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] { - return &MemoCache[H, T]{ - capacity: capacity, - cache: make(map[string]*list.Element), - evictionList: list.New(), - hashableItems: make(map[string]T), - } -} - -// Capacity is a method that returns the capacity of the MemoCache. -func (m *MemoCache[H, T]) Capacity() int { - return m.capacity -} - -// Size is a method that returns the current size of the MemoCache. It is -// the number of items currently stored in the cache. -func (m *MemoCache[H, T]) Size() int { - m.mutex.Lock() - defer m.mutex.Unlock() - return m.evictionList.Len() -} - -// Get is a method that returns the value associated with the given -// hashable item in the MemoCache. If there is no corresponding value, the -// method returns nil. -func (m *MemoCache[H, T]) Get(h H) (T, bool) { - m.mutex.Lock() - defer m.mutex.Unlock() - - hashedKey := h.Hash() - if element, found := m.cache[hashedKey]; found { - m.evictionList.MoveToFront(element) - return element.Value.(*entry[T]).value, true - } - var result T - return result, false -} - -// Set is a method that sets the value for the given hashable item in the -// MemoCache. If the cache is at capacity, it evicts the least recently -// used item before adding the new item. -func (m *MemoCache[H, T]) Set(h H, value T) { - m.mutex.Lock() - defer m.mutex.Unlock() - - hashedKey := h.Hash() - if element, found := m.cache[hashedKey]; found { - m.evictionList.MoveToFront(element) - element.Value.(*entry[T]).value = value - return - } - - // Check if the cache is at capacity - if m.evictionList.Len() >= m.capacity { - // Evict the least recently used item from the cache - toEvict := m.evictionList.Back() - if toEvict != nil { - evictedEntry := m.evictionList.Remove(toEvict).(*entry[T]) - delete(m.cache, evictedEntry.key) - delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items - } - } - - // Add the value to the cache and the evictionList - newEntry := &entry[T]{ - key: hashedKey, - value: value, - } - element := m.evictionList.PushFront(newEntry) - m.cache[hashedKey] = element - m.hashableItems[hashedKey] = value // if you're keeping track of original items -} - -// HString is a type that implements the Hasher interface for strings. -type HString string - -// Hash is a method that returns the hash of the string. -func (h HString) Hash() string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(h))) -} - -// HInt is a type that implements the Hasher interface for integers. -type HInt int - -// Hash is a method that returns the hash of the integer. -func (h HInt) Hash() string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h)))) -} diff --git a/packages/tui/internal/components/textarea/runeutil.go b/packages/tui/internal/components/textarea/runeutil.go deleted file mode 100644 index c4fc87f80..000000000 --- a/packages/tui/internal/components/textarea/runeutil.go +++ /dev/null @@ -1,102 +0,0 @@ -// Package runeutil provides utility functions for tidying up incoming runes -// from Key messages. -package textarea - -import ( - "unicode" - "unicode/utf8" -) - -// Sanitizer is a helper for bubble widgets that want to process -// Runes from input key messages. -type Sanitizer interface { - // Sanitize removes control characters from runes in a KeyRunes - // message, and optionally replaces newline/carriage return/tabs by a - // specified character. - // - // The rune array is modified in-place if possible. In that case, the - // returned slice is the original slice shortened after the control - // characters have been removed/translated. - Sanitize(runes []rune) []rune -} - -// NewSanitizer constructs a rune sanitizer. -func NewSanitizer(opts ...Option) Sanitizer { - s := sanitizer{ - replaceNewLine: []rune("\n"), - replaceTab: []rune(" "), - } - for _, o := range opts { - s = o(s) - } - return &s -} - -// Option is the type of option that can be passed to Sanitize(). -type Option func(sanitizer) sanitizer - -// ReplaceTabs replaces tabs by the specified string. -func ReplaceTabs(tabRepl string) Option { - return func(s sanitizer) sanitizer { - s.replaceTab = []rune(tabRepl) - return s - } -} - -// ReplaceNewlines replaces newline characters by the specified string. -func ReplaceNewlines(nlRepl string) Option { - return func(s sanitizer) sanitizer { - s.replaceNewLine = []rune(nlRepl) - return s - } -} - -func (s *sanitizer) Sanitize(runes []rune) []rune { - // dstrunes are where we are storing the result. - dstrunes := runes[:0:len(runes)] - // copied indicates whether dstrunes is an alias of runes - // or a copy. We need a copy when dst moves past src. - // We use this as an optimization to avoid allocating - // a new rune slice in the common case where the output - // is smaller or equal to the input. - copied := false - - for src := 0; src < len(runes); src++ { - r := runes[src] - switch { - case r == utf8.RuneError: - // skip - - case r == '\r' || r == '\n': - if len(dstrunes)+len(s.replaceNewLine) > src && !copied { - dst := len(dstrunes) - dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine)) - copy(dstrunes, runes[:dst]) - copied = true - } - dstrunes = append(dstrunes, s.replaceNewLine...) - - case r == '\t': - if len(dstrunes)+len(s.replaceTab) > src && !copied { - dst := len(dstrunes) - dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab)) - copy(dstrunes, runes[:dst]) - copied = true - } - dstrunes = append(dstrunes, s.replaceTab...) - - case unicode.IsControl(r): - // Other control characters: skip. - - default: - // Keep the character. - dstrunes = append(dstrunes, runes[src]) - } - } - return dstrunes -} - -type sanitizer struct { - replaceNewLine []rune - replaceTab []rune -} diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go deleted file mode 100644 index 6e6695917..000000000 --- a/packages/tui/internal/components/textarea/textarea.go +++ /dev/null @@ -1,2377 +0,0 @@ -package textarea - -import ( - "crypto/sha256" - "fmt" - "image/color" - "strconv" - "strings" - "time" - "unicode" - - "slices" - - "github.com/charmbracelet/bubbles/v2/cursor" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - rw "github.com/mattn/go-runewidth" - "github.com/rivo/uniseg" - "github.com/sst/opencode/internal/attachment" -) - -const ( - minHeight = 1 - defaultHeight = 1 - defaultWidth = 40 - defaultCharLimit = 0 // no limit - defaultMaxHeight = 99 - defaultMaxWidth = 500 - - // XXX: in v2, make max lines dynamic and default max lines configurable. - maxLines = 10000 -) - -// Helper functions for converting between runes and any slices - -// runesToInterfaces converts a slice of runes to a slice of interfaces -func runesToInterfaces(runes []rune) []any { - result := make([]any, len(runes)) - for i, r := range runes { - result[i] = r - } - return result -} - -// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes) -func interfacesToRunes(items []any) []rune { - var result []rune - for _, item := range items { - switch val := item.(type) { - case rune: - result = append(result, val) - case *attachment.Attachment: - result = append(result, []rune(val.Display)...) - } - } - return result -} - -// copyInterfaceSlice creates a copy of an any slice -func copyInterfaceSlice(src []any) []any { - dst := make([]any, len(src)) - copy(dst, src) - return dst -} - -// interfacesToString converts a slice of interfaces to a string for display -func interfacesToString(items []any) string { - var s strings.Builder - for _, item := range items { - switch val := item.(type) { - case rune: - s.WriteRune(val) - case *attachment.Attachment: - s.WriteString(val.Display) - } - } - return s.String() -} - -// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment. -// This allows for proper highlighting even when the cursor is technically at the position -// after the attachment object in the underlying slice. -func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) { - if m.row >= len(m.value) { - return nil, -1, -1 - } - - row := m.value[m.row] - col := m.col - - if col < 0 || col > len(row) { - return nil, -1, -1 - } - - // Check if the cursor is at the same index as an attachment. - if col < len(row) { - if att, ok := row[col].(*attachment.Attachment); ok { - return att, col, col - } - } - - // Check if the cursor is immediately after an attachment. This is a common - // state, for example, after just inserting one. - if col > 0 && col <= len(row) { - if att, ok := row[col-1].(*attachment.Attachment); ok { - return att, col - 1, col - 1 - } - } - - return nil, -1, -1 -} - -// renderLineWithAttachments renders a line with proper attachment highlighting -func (m Model) renderLineWithAttachments( - items []any, - style lipgloss.Style, -) string { - var s strings.Builder - currentAttachment, _, _ := m.isAttachmentAtCursor() - - for _, item := range items { - switch val := item.(type) { - case rune: - s.WriteString(style.Render(string(val))) - case *attachment.Attachment: - // Check if this is the attachment the cursor is currently on - if currentAttachment != nil && currentAttachment.ID == val.ID { - // Cursor is on this attachment, highlight it - s.WriteString(m.Styles.SelectedAttachment.Render(val.Display)) - } else { - s.WriteString(m.Styles.Attachment.Render(val.Display)) - } - } - } - return s.String() -} - -// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune -func getRuneAt(items []any, index int) rune { - if index < 0 || index >= len(items) { - return 0 - } - if r, ok := items[index].(rune); ok { - return r - } - return 0 -} - -// isSpaceAt checks if the item at index is a space rune -func isSpaceAt(items []any, index int) bool { - r := getRuneAt(items, index) - return r != 0 && unicode.IsSpace(r) -} - -// setRuneAt safely sets a rune at a specific position if it's a rune -func setRuneAt(items []any, index int, r rune) { - if index >= 0 && index < len(items) { - if _, ok := items[index].(rune); ok { - items[index] = r - } - } -} - -// Internal messages for clipboard operations. -type ( - pasteMsg string - pasteErrMsg struct{ error } -) - -// KeyMap is the key bindings for different actions within the textarea. -type KeyMap struct { - CharacterBackward key.Binding - CharacterForward key.Binding - DeleteAfterCursor key.Binding - DeleteBeforeCursor key.Binding - DeleteCharacterBackward key.Binding - DeleteCharacterForward key.Binding - DeleteWordBackward key.Binding - DeleteWordForward key.Binding - InsertNewline key.Binding - LineEnd key.Binding - LineNext key.Binding - LinePrevious key.Binding - LineStart key.Binding - Paste key.Binding - WordBackward key.Binding - WordForward key.Binding - InputBegin key.Binding - InputEnd key.Binding - - UppercaseWordForward key.Binding - LowercaseWordForward key.Binding - CapitalizeWordForward key.Binding - - TransposeCharacterBackward key.Binding -} - -// DefaultKeyMap returns the default set of key bindings for navigating and acting -// upon the textarea. -func DefaultKeyMap() KeyMap { - return KeyMap{ - CharacterForward: key.NewBinding( - key.WithKeys("right", "ctrl+f"), - key.WithHelp("right", "character forward"), - ), - CharacterBackward: key.NewBinding( - key.WithKeys("left", "ctrl+b"), - key.WithHelp("left", "character backward"), - ), - WordForward: key.NewBinding( - key.WithKeys("alt+right", "ctrl+right", "alt+f"), - key.WithHelp("alt+right", "word forward"), - ), - WordBackward: key.NewBinding( - key.WithKeys("alt+left", "ctrl+left", "alt+b"), - key.WithHelp("alt+left", "word backward"), - ), - LineNext: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("down", "next line"), - ), - LinePrevious: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("up", "previous line"), - ), - DeleteWordBackward: key.NewBinding( - key.WithKeys("alt+backspace", "ctrl+w"), - key.WithHelp("alt+backspace", "delete word backward"), - ), - DeleteWordForward: key.NewBinding( - key.WithKeys("alt+delete", "alt+d"), - key.WithHelp("alt+delete", "delete word forward"), - ), - DeleteAfterCursor: key.NewBinding( - key.WithKeys("ctrl+k"), - key.WithHelp("ctrl+k", "delete after cursor"), - ), - DeleteBeforeCursor: key.NewBinding( - key.WithKeys("ctrl+u"), - key.WithHelp("ctrl+u", "delete before cursor"), - ), - InsertNewline: key.NewBinding( - key.WithKeys("enter", "ctrl+m"), - key.WithHelp("enter", "insert newline"), - ), - DeleteCharacterBackward: key.NewBinding( - key.WithKeys("backspace", "ctrl+h"), - key.WithHelp("backspace", "delete character backward"), - ), - DeleteCharacterForward: key.NewBinding( - key.WithKeys("delete", "ctrl+d"), - key.WithHelp("delete", "delete character forward"), - ), - LineStart: key.NewBinding( - key.WithKeys("home", "ctrl+a"), - key.WithHelp("home", "line start"), - ), - LineEnd: key.NewBinding( - key.WithKeys("end", "ctrl+e"), - key.WithHelp("end", "line end"), - ), - Paste: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste"), - ), - InputBegin: key.NewBinding( - key.WithKeys("alt+<", "ctrl+home"), - key.WithHelp("alt+<", "input begin"), - ), - InputEnd: key.NewBinding( - key.WithKeys("alt+>", "ctrl+end"), - key.WithHelp("alt+>", "input end"), - ), - - CapitalizeWordForward: key.NewBinding( - key.WithKeys("alt+c"), - key.WithHelp("alt+c", "capitalize word forward"), - ), - LowercaseWordForward: key.NewBinding( - key.WithKeys("alt+l"), - key.WithHelp("alt+l", "lowercase word forward"), - ), - UppercaseWordForward: key.NewBinding( - key.WithKeys("alt+u"), - key.WithHelp("alt+u", "uppercase word forward"), - ), - - TransposeCharacterBackward: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("ctrl+t", "transpose character backward"), - ), - } -} - -// LineInfo is a helper for keeping track of line information regarding -// soft-wrapped lines. -type LineInfo struct { - // Width is the number of columns in the line. - Width int - - // CharWidth is the number of characters in the line to account for - // double-width runes. - CharWidth int - - // Height is the number of rows in the line. - Height int - - // StartColumn is the index of the first column of the line. - StartColumn int - - // ColumnOffset is the number of columns that the cursor is offset from the - // start of the line. - ColumnOffset int - - // RowOffset is the number of rows that the cursor is offset from the start - // of the line. - RowOffset int - - // CharOffset is the number of characters that the cursor is offset - // from the start of the line. This will generally be equivalent to - // ColumnOffset, but will be different there are double-width runes before - // the cursor. - CharOffset int -} - -// CursorStyle is the style for real and virtual cursors. -type CursorStyle struct { - // Style styles the cursor block. - // - // For real cursors, the foreground color set here will be used as the - // cursor color. - Color color.Color - - // Shape is the cursor shape. The following shapes are available: - // - // - tea.CursorBlock - // - tea.CursorUnderline - // - tea.CursorBar - // - // This is only used for real cursors. - Shape tea.CursorShape - - // CursorBlink determines whether or not the cursor should blink. - Blink bool - - // BlinkSpeed is the speed at which the virtual cursor blinks. This has no - // effect on real cursors as well as no effect if the cursor is set not to - // [CursorBlink]. - // - // By default, the blink speed is set to about 500ms. - BlinkSpeed time.Duration -} - -// Styles are the styles for the textarea, separated into focused and blurred -// states. The appropriate styles will be chosen based on the focus state of -// the textarea. -type Styles struct { - Focused StyleState - Blurred StyleState - Cursor CursorStyle - Attachment lipgloss.Style - SelectedAttachment lipgloss.Style -} - -// StyleState that will be applied to the text area. -// -// StyleState can be applied to focused and unfocused states to change the styles -// depending on the focus state. -// -// For an introduction to styling with Lip Gloss see: -// https://github.com/charmbracelet/lipgloss -type StyleState struct { - Base lipgloss.Style - Text lipgloss.Style - LineNumber lipgloss.Style - CursorLineNumber lipgloss.Style - CursorLine lipgloss.Style - EndOfBuffer lipgloss.Style - Placeholder lipgloss.Style - Prompt lipgloss.Style -} - -func (s StyleState) computedCursorLine() lipgloss.Style { - return s.CursorLine.Inherit(s.Base).Inline(true) -} - -func (s StyleState) computedCursorLineNumber() lipgloss.Style { - return s.CursorLineNumber. - Inherit(s.CursorLine). - Inherit(s.Base). - Inline(true) -} - -func (s StyleState) computedEndOfBuffer() lipgloss.Style { - return s.EndOfBuffer.Inherit(s.Base).Inline(true) -} - -func (s StyleState) computedLineNumber() lipgloss.Style { - return s.LineNumber.Inherit(s.Base).Inline(true) -} - -func (s StyleState) computedPlaceholder() lipgloss.Style { - return s.Placeholder.Inherit(s.Base).Inline(true) -} - -func (s StyleState) computedPrompt() lipgloss.Style { - return s.Prompt.Inherit(s.Base).Inline(true) -} - -func (s StyleState) computedText() lipgloss.Style { - return s.Text.Inherit(s.Base).Inline(true) -} - -// line is the input to the text wrapping function. This is stored in a struct -// so that it can be hashed and memoized. -type line struct { - content []any // Contains runes and *Attachment - width int -} - -// Hash returns a hash of the line. -func (w line) Hash() string { - var s strings.Builder - for _, item := range w.content { - switch v := item.(type) { - case rune: - s.WriteRune(v) - case *attachment.Attachment: - s.WriteString(v.ID) - } - } - v := fmt.Sprintf("%s:%d", s.String(), w.width) - return fmt.Sprintf("%x", sha256.Sum256([]byte(v))) -} - -// Model is the Bubble Tea model for this text area element. -type Model struct { - Err error - - // General settings. - cache *MemoCache[line, [][]any] - - // Prompt is printed at the beginning of each line. - // - // When changing the value of Prompt after the model has been - // initialized, ensure that SetWidth() gets called afterwards. - // - // See also [SetPromptFunc] for a dynamic prompt. - Prompt string - - // Placeholder is the text displayed when the user - // hasn't entered anything yet. - Placeholder string - - // ShowLineNumbers, if enabled, causes line numbers to be printed - // after the prompt. - ShowLineNumbers bool - - // EndOfBufferCharacter is displayed at the end of the input. - EndOfBufferCharacter rune - - // KeyMap encodes the keybindings recognized by the widget. - KeyMap KeyMap - - // Styling. FocusedStyle and BlurredStyle are used to style the textarea in - // focused and blurred states. - Styles Styles - - // virtualCursor manages the virtual cursor. - virtualCursor cursor.Model - - // VirtualCursor determines whether or not to use the virtual cursor. If - // set to false, use [Model.Cursor] to return a real cursor for rendering. - VirtualCursor bool - - // CharLimit is the maximum number of characters this input element will - // accept. If 0 or less, there's no limit. - CharLimit int - - // MaxHeight is the maximum height of the text area in rows. If 0 or less, - // there's no limit. - MaxHeight int - - // MaxWidth is the maximum width of the text area in columns. If 0 or less, - // there's no limit. - MaxWidth int - - // If promptFunc is set, it replaces Prompt as a generator for - // prompt strings at the beginning of each line. - promptFunc func(line int) string - - // promptWidth is the width of the prompt. - promptWidth int - - // width is the maximum number of characters that can be displayed at once. - // If 0 or less this setting is ignored. - width int - - // height is the maximum number of lines that can be displayed at once. It - // essentially treats the text field like a vertically scrolling viewport - // if there are more lines than the permitted height. - height int - - // Underlying text value. Contains either rune or *Attachment types. - value [][]any - - // focus indicates whether user input focus should be on this input - // component. When false, ignore keyboard input and hide the cursor. - focus bool - - // Cursor column (slice index). - col int - - // Cursor row. - row int - - // Last character offset, used to maintain state when the cursor is moved - // vertically such that we can maintain the same navigating position. - lastCharOffset int - - // rune sanitizer for input. - rsan Sanitizer -} - -// New creates a new model with default settings. -func New() Model { - cur := cursor.New() - - styles := DefaultDarkStyles() - - m := Model{ - CharLimit: defaultCharLimit, - MaxHeight: defaultMaxHeight, - MaxWidth: defaultMaxWidth, - Prompt: lipgloss.ThickBorder().Left + " ", - Styles: styles, - cache: NewMemoCache[line, [][]any](maxLines), - EndOfBufferCharacter: ' ', - ShowLineNumbers: true, - VirtualCursor: true, - virtualCursor: cur, - KeyMap: DefaultKeyMap(), - - value: make([][]any, minHeight, maxLines), - focus: false, - col: 0, - row: 0, - } - - m.SetWidth(defaultWidth) - m.SetHeight(defaultHeight) - - return m -} - -// DefaultStyles returns the default styles for focused and blurred states for -// the textarea. -func DefaultStyles(isDark bool) Styles { - lightDark := lipgloss.LightDark(isDark) - - var s Styles - s.Focused = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle(). - Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), - CursorLineNumber: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), - EndOfBuffer: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(), - } - s.Blurred = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - CursorLineNumber: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - EndOfBuffer: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(). - Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - } - s.Attachment = lipgloss.NewStyle(). - Background(lipgloss.Color("11")). - Foreground(lipgloss.Color("0")) - s.SelectedAttachment = lipgloss.NewStyle(). - Background(lipgloss.Color("11")). - Foreground(lipgloss.Color("0")) - s.Cursor = CursorStyle{ - Color: lipgloss.Color("7"), - Shape: tea.CursorBlock, - Blink: true, - } - return s -} - -// DefaultLightStyles returns the default styles for a light background. -func DefaultLightStyles() Styles { - return DefaultStyles(false) -} - -// DefaultDarkStyles returns the default styles for a dark background. -func DefaultDarkStyles() Styles { - return DefaultStyles(true) -} - -// updateVirtualCursorStyle sets styling on the virtual cursor based on the -// textarea's style settings. -func (m *Model) updateVirtualCursorStyle() { - if !m.VirtualCursor { - m.virtualCursor.SetMode(cursor.CursorHide) - return - } - - m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color) - - // By default, the blink speed of the cursor is set to a default - // internally. - if m.Styles.Cursor.Blink { - if m.Styles.Cursor.BlinkSpeed > 0 { - m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed - } - m.virtualCursor.SetMode(cursor.CursorBlink) - return - } - m.virtualCursor.SetMode(cursor.CursorStatic) -} - -// SetValue sets the value of the text input. -func (m *Model) SetValue(s string) { - m.Reset() - m.InsertString(s) -} - -// InsertString inserts a string at the cursor position. -func (m *Model) InsertString(s string) { - m.InsertRunesFromUserInput([]rune(s)) -} - -// InsertRune inserts a rune at the cursor position. -func (m *Model) InsertRune(r rune) { - m.InsertRunesFromUserInput([]rune{r}) -} - -// InsertAttachment inserts an attachment at the cursor position. -func (m *Model) InsertAttachment(att *attachment.Attachment) { - if m.CharLimit > 0 { - availSpace := m.CharLimit - m.Length() - // If the char limit's been reached, cancel. - if availSpace <= 0 { - return - } - } - - // Insert the attachment at the current cursor position - m.value[m.row] = append( - m.value[m.row][:m.col], - append([]any{att}, m.value[m.row][m.col:]...)...) - m.col++ - m.SetCursorColumn(m.col) -} - -// removeAttachmentAtCursor replaces the attachment at or immediately before the -// cursor with its textual display and positions the cursor at the end of the -// inserted text. Returns true if an attachment was removed. -func (m *Model) removeAttachmentAtCursor() bool { - att, startIdx, _ := m.isAttachmentAtCursor() - if att == nil { - return false - } - // Replace the attachment element with the display runes - before := m.value[m.row][:startIdx] - after := m.value[m.row][startIdx+1:] - replacement := runesToInterfaces([]rune(att.Display)) - newRow := make([]any, 0, len(before)+len(replacement)+len(after)) - newRow = append(newRow, before...) - newRow = append(newRow, replacement...) - newRow = append(newRow, after...) - m.value[m.row] = newRow - m.col = startIdx + len(replacement) - m.SetCursorColumn(m.col) - return true -} - -// ReplaceRange replaces text from startCol to endCol on the current row with the given string. -// This preserves attachments outside the replaced range. -func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { - if m.row >= len(m.value) || startCol < 0 || endCol < startCol { - return - } - - // Ensure bounds are within the current row - rowLen := len(m.value[m.row]) - startCol = max(0, min(startCol, rowLen)) - endCol = max(startCol, min(endCol, rowLen)) - - // Create new row content: before + replacement + after - before := m.value[m.row][:startCol] - after := m.value[m.row][endCol:] - replacementRunes := runesToInterfaces([]rune(replacement)) - - // Combine the parts - newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after)) - newRow = append(newRow, before...) - newRow = append(newRow, replacementRunes...) - newRow = append(newRow, after...) - - m.value[m.row] = newRow - - // Position cursor at end of replacement - m.col = startCol + len(replacementRunes) - m.SetCursorColumn(m.col) -} - -// CurrentRowLength returns the length of the current row. -func (m *Model) CurrentRowLength() int { - if m.row >= len(m.value) { - return 0 - } - return len(m.value[m.row]) -} - -// GetAttachments returns all attachments in the textarea with accurate position indices. -func (m Model) GetAttachments() []*attachment.Attachment { - var attachments []*attachment.Attachment - position := 0 // Track absolute position in the text - - for rowIdx, row := range m.value { - colPosition := 0 // Track position within the current row - - for _, item := range row { - switch v := item.(type) { - case *attachment.Attachment: - // Clone the attachment to avoid modifying the original - att := *v - att.StartIndex = position + colPosition - att.EndIndex = position + colPosition + len(v.Display) - attachments = append(attachments, &att) - colPosition += len(v.Display) - case rune: - colPosition++ - } - } - - // Add newline character position (except for last row) - if rowIdx < len(m.value)-1 { - position += colPosition + 1 // +1 for newline - } else { - position += colPosition - } - } - - return attachments -} - -// InsertRunesFromUserInput inserts runes at the current cursor position. -func (m *Model) InsertRunesFromUserInput(runes []rune) { - // Clean up any special characters in the input provided by the - // clipboard. This avoids bugs due to e.g. tab characters and - // whatnot. - runes = m.san().Sanitize(runes) - - if m.CharLimit > 0 { - availSpace := m.CharLimit - m.Length() - // If the char limit's been reached, cancel. - if availSpace <= 0 { - return - } - // If there's not enough space to paste the whole thing cut the pasted - // runes down so they'll fit. - if availSpace < len(runes) { - runes = runes[:availSpace] - } - } - - // Split the input into lines. - var lines [][]rune - lstart := 0 - for i := range runes { - if runes[i] == '\n' { - // Queue a line to become a new row in the text area below. - // Beware to clamp the max capacity of the slice, to ensure no - // data from different rows get overwritten when later edits - // will modify this line. - lines = append(lines, runes[lstart:i:i]) - lstart = i + 1 - } - } - if lstart <= len(runes) { - // The last line did not end with a newline character. - // Take it now. - lines = append(lines, runes[lstart:]) - } - - // Obey the maximum line limit. - if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines { - allowedHeight := max(0, maxLines-len(m.value)+1) - lines = lines[:allowedHeight] - } - - if len(lines) == 0 { - // Nothing left to insert. - return - } - - // Save the remainder of the original line at the current - // cursor position. - tail := copyInterfaceSlice(m.value[m.row][m.col:]) - - // Paste the first line at the current cursor position. - m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...) - m.col += len(lines[0]) - - if numExtraLines := len(lines) - 1; numExtraLines > 0 { - // Add the new lines. - // We try to reuse the slice if there's already space. - var newGrid [][]any - if cap(m.value) >= len(m.value)+numExtraLines { - // Can reuse the extra space. - newGrid = m.value[:len(m.value)+numExtraLines] - } else { - // No space left; need a new slice. - newGrid = make([][]any, len(m.value)+numExtraLines) - copy(newGrid, m.value[:m.row+1]) - } - // Add all the rows that were after the cursor in the original - // grid at the end of the new grid. - copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:]) - m.value = newGrid - // Insert all the new lines in the middle. - for _, l := range lines[1:] { - m.row++ - m.value[m.row] = runesToInterfaces(l) - m.col = len(l) - } - } - - // Finally add the tail at the end of the last line inserted. - m.value[m.row] = append(m.value[m.row], tail...) - - m.SetCursorColumn(m.col) -} - -// Value returns the value of the text input. -func (m Model) Value() string { - if m.value == nil { - return "" - } - - var v strings.Builder - for _, l := range m.value { - for _, item := range l { - switch val := item.(type) { - case rune: - v.WriteRune(val) - case *attachment.Attachment: - v.WriteString(val.Display) - } - } - v.WriteByte('\n') - } - - return strings.TrimSuffix(v.String(), "\n") -} - -// Length returns the number of characters currently in the text input. -func (m *Model) Length() int { - var l int - for _, row := range m.value { - for _, item := range row { - switch val := item.(type) { - case rune: - l += rw.RuneWidth(val) - case *attachment.Attachment: - l += uniseg.StringWidth(val.Display) - } - } - } - // We add len(m.value) to include the newline characters. - return l + len(m.value) - 1 -} - -// LineCount returns the number of lines that are currently in the text input. -func (m *Model) LineCount() int { - return m.ContentHeight() -} - -// Line returns the line position. -func (m Model) Line() int { - return m.row -} - -// CursorColumn returns the cursor's column position (slice index). -func (m Model) CursorColumn() int { - return m.col -} - -// LastRuneIndex returns the index of the last occurrence of a rune on the current line, -// searching backwards from the current cursor position. -// Returns -1 if the rune is not found before the cursor. -func (m Model) LastRuneIndex(r rune) int { - if m.row >= len(m.value) { - return -1 - } - // Iterate backwards from just before the cursor position - for i := m.col - 1; i >= 0; i-- { - if i < len(m.value[m.row]) { - if item, ok := m.value[m.row][i].(rune); ok && item == r { - return i - } - } - } - return -1 -} - -func (m *Model) Newline() { - if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { - return - } - m.col = clamp(m.col, 0, len(m.value[m.row])) - m.splitLine(m.row, m.col) -} - -// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index. -// This is used to maintain the cursor's horizontal position when moving vertically. -func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { - if row < 0 || row >= len(m.value) { - return 0 - } - - offset := 0 - // Find the slice index that corresponds to the visual offset. - for i, item := range m.value[row] { - var itemWidth int - switch v := item.(type) { - case rune: - itemWidth = rw.RuneWidth(v) - case *attachment.Attachment: - itemWidth = uniseg.StringWidth(v.Display) - } - - // If the target offset falls within the current item, this is our index. - if offset+itemWidth > charOffset { - // Decide whether to stick with the previous index or move to the current - // one based on which is closer to the target offset. - if (charOffset - offset) > ((offset + itemWidth) - charOffset) { - return i + 1 - } - return i - } - offset += itemWidth - } - - return len(m.value[row]) -} - -// CursorDown moves the cursor down by one line. -func (m *Model) CursorDown() { - li := m.LineInfo() - charOffset := max(m.lastCharOffset, li.CharOffset) - m.lastCharOffset = charOffset - - if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 { - // Move to the next model line - m.row++ - - // We want to land on the first wrapped line of the new model line. - grid := m.memoizedWrap(m.value[m.row], m.width) - targetLineContent := grid[0] - - // Find position within the first wrapped line. - offset := 0 - colInLine := 0 - for i, item := range targetLineContent { - var itemWidth int - switch v := item.(type) { - case rune: - itemWidth = rw.RuneWidth(v) - case *attachment.Attachment: - itemWidth = uniseg.StringWidth(v.Display) - } - if offset+itemWidth > charOffset { - // Decide whether to stick with the previous index or move to the current - // one based on which is closer to the target offset. - if (charOffset - offset) > ((offset + itemWidth) - charOffset) { - colInLine = i + 1 - } else { - colInLine = i - } - goto foundNextLine - } - offset += itemWidth - } - colInLine = len(targetLineContent) - foundNextLine: - m.col = colInLine // startCol is 0 for the first wrapped line - } else if li.RowOffset+1 < li.Height { - // Move to the next wrapped line within the same model line - grid := m.memoizedWrap(m.value[m.row], m.width) - targetLineContent := grid[li.RowOffset+1] - - startCol := 0 - for i := 0; i < li.RowOffset+1; i++ { - startCol += len(grid[i]) - } - - // Find position within the target wrapped line. - offset := 0 - colInLine := 0 - for i, item := range targetLineContent { - var itemWidth int - switch v := item.(type) { - case rune: - itemWidth = rw.RuneWidth(v) - case *attachment.Attachment: - itemWidth = uniseg.StringWidth(v.Display) - } - if offset+itemWidth > charOffset { - // Decide whether to stick with the previous index or move to the current - // one based on which is closer to the target offset. - if (charOffset - offset) > ((offset + itemWidth) - charOffset) { - colInLine = i + 1 - } else { - colInLine = i - } - goto foundSameLine - } - offset += itemWidth - } - colInLine = len(targetLineContent) - foundSameLine: - m.col = startCol + colInLine - } - m.SetCursorColumn(m.col) -} - -// CursorUp moves the cursor up by one line. -func (m *Model) CursorUp() { - li := m.LineInfo() - charOffset := max(m.lastCharOffset, li.CharOffset) - m.lastCharOffset = charOffset - - if li.RowOffset <= 0 && m.row > 0 { - // Move to the previous model line. We want to land on the last wrapped - // line of the previous model line. - m.row-- - grid := m.memoizedWrap(m.value[m.row], m.width) - targetLineContent := grid[len(grid)-1] - - // Find start of last wrapped line. - startCol := len(m.value[m.row]) - len(targetLineContent) - - // Find position within the last wrapped line. - offset := 0 - colInLine := 0 - for i, item := range targetLineContent { - var itemWidth int - switch v := item.(type) { - case rune: - itemWidth = rw.RuneWidth(v) - case *attachment.Attachment: - itemWidth = uniseg.StringWidth(v.Display) - } - if offset+itemWidth > charOffset { - // Decide whether to stick with the previous index or move to the current - // one based on which is closer to the target offset. - if (charOffset - offset) > ((offset + itemWidth) - charOffset) { - colInLine = i + 1 - } else { - colInLine = i - } - goto foundPrevLine - } - offset += itemWidth - } - colInLine = len(targetLineContent) - foundPrevLine: - m.col = startCol + colInLine - } else if li.RowOffset > 0 { - // Move to the previous wrapped line within the same model line. - grid := m.memoizedWrap(m.value[m.row], m.width) - targetLineContent := grid[li.RowOffset-1] - - startCol := 0 - for i := 0; i < li.RowOffset-1; i++ { - startCol += len(grid[i]) - } - - // Find position within the target wrapped line. - offset := 0 - colInLine := 0 - for i, item := range targetLineContent { - var itemWidth int - switch v := item.(type) { - case rune: - itemWidth = rw.RuneWidth(v) - case *attachment.Attachment: - itemWidth = uniseg.StringWidth(v.Display) - } - if offset+itemWidth > charOffset { - // Decide whether to stick with the previous index or move to the current - // one based on which is closer to the target offset. - if (charOffset - offset) > ((offset + itemWidth) - charOffset) { - colInLine = i + 1 - } else { - colInLine = i - } - goto foundSameLine - } - offset += itemWidth - } - colInLine = len(targetLineContent) - foundSameLine: - m.col = startCol + colInLine - } - m.SetCursorColumn(m.col) -} - -// SetCursorColumn moves the cursor to the given position. If the position is -// out of bounds the cursor will be moved to the start or end accordingly. -func (m *Model) SetCursorColumn(col int) { - m.col = clamp(col, 0, len(m.value[m.row])) - // Any time that we move the cursor horizontally we need to reset the last - // offset so that the horizontal position when navigating is adjusted. - m.lastCharOffset = 0 -} - -// CursorStart moves the cursor to the start of the input field. -func (m *Model) CursorStart() { - m.SetCursorColumn(0) -} - -// CursorEnd moves the cursor to the end of the input field. -func (m *Model) CursorEnd() { - m.SetCursorColumn(len(m.value[m.row])) -} - -func (m *Model) IsCursorAtEnd() bool { - return m.CursorColumn() == len(m.value[m.row]) -} - -// Focused returns the focus state on the model. -func (m Model) Focused() bool { - return m.focus -} - -// activeStyle returns the appropriate set of styles to use depending on -// whether the textarea is focused or blurred. -func (m Model) activeStyle() *StyleState { - if m.focus { - return &m.Styles.Focused - } - return &m.Styles.Blurred -} - -// Focus sets the focus state on the model. When the model is in focus it can -// receive keyboard input and the cursor will be hidden. -func (m *Model) Focus() tea.Cmd { - m.focus = true - return m.virtualCursor.Focus() -} - -// Blur removes the focus state on the model. When the model is blurred it can -// not receive keyboard input and the cursor will be hidden. -func (m *Model) Blur() { - m.focus = false - m.virtualCursor.Blur() -} - -// Reset sets the input to its default state with no input. -func (m *Model) Reset() { - m.value = make([][]any, minHeight, maxLines) - m.col = 0 - m.row = 0 - m.SetCursorColumn(0) -} - -// san initializes or retrieves the rune sanitizer. -func (m *Model) san() Sanitizer { - if m.rsan == nil { - // Textinput has all its input on a single line so collapse - // newlines/tabs to single spaces. - m.rsan = NewSanitizer() - } - return m.rsan -} - -// deleteBeforeCursor deletes all text before the cursor. Returns whether or -// not the cursor blink should be reset. -func (m *Model) deleteBeforeCursor() { - m.value[m.row] = m.value[m.row][m.col:] - m.SetCursorColumn(0) -} - -// deleteAfterCursor deletes all text after the cursor. Returns whether or not -// the cursor blink should be reset. If input is masked delete everything after -// the cursor so as not to reveal word breaks in the masked input. -func (m *Model) deleteAfterCursor() { - m.value[m.row] = m.value[m.row][:m.col] - m.SetCursorColumn(len(m.value[m.row])) -} - -// transposeLeft exchanges the runes at the cursor and immediately -// before. No-op if the cursor is at the beginning of the line. If -// the cursor is not at the end of the line yet, moves the cursor to -// the right. -func (m *Model) transposeLeft() { - if m.col == 0 || len(m.value[m.row]) < 2 { - return - } - if m.col >= len(m.value[m.row]) { - m.SetCursorColumn(m.col - 1) - } - m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1] - if m.col < len(m.value[m.row]) { - m.SetCursorColumn(m.col + 1) - } -} - -// deleteWordLeft deletes the word left to the cursor. Returns whether or not -// the cursor blink should be reset. -func (m *Model) deleteWordLeft() { - if m.col == 0 || len(m.value[m.row]) == 0 { - return - } - - // Linter note: it's critical that we acquire the initial cursor position - // here prior to altering it via SetCursor() below. As such, moving this - // call into the corresponding if clause does not apply here. - oldCol := m.col //nolint:ifshort - - m.SetCursorColumn(m.col - 1) - for isSpaceAt(m.value[m.row], m.col) { - if m.col <= 0 { - break - } - // ignore series of whitespace before cursor - m.SetCursorColumn(m.col - 1) - } - - for m.col > 0 { - if !isSpaceAt(m.value[m.row], m.col) { - m.SetCursorColumn(m.col - 1) - } else { - if m.col > 0 { - // keep the previous space - m.SetCursorColumn(m.col + 1) - } - break - } - } - - if oldCol > len(m.value[m.row]) { - m.value[m.row] = m.value[m.row][:m.col] - } else { - m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...) - } -} - -// deleteWordRight deletes the word right to the cursor. -func (m *Model) deleteWordRight() { - if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 { - return - } - - oldCol := m.col - - for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) { - // ignore series of whitespace after cursor - m.SetCursorColumn(m.col + 1) - } - - for m.col < len(m.value[m.row]) { - if !isSpaceAt(m.value[m.row], m.col) { - m.SetCursorColumn(m.col + 1) - } else { - break - } - } - - if m.col > len(m.value[m.row]) { - m.value[m.row] = m.value[m.row][:oldCol] - } else { - m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...) - } - - m.SetCursorColumn(oldCol) -} - -// characterRight moves the cursor one character to the right. -func (m *Model) characterRight() { - if m.col < len(m.value[m.row]) { - m.SetCursorColumn(m.col + 1) - } else { - if m.row < len(m.value)-1 { - m.row++ - m.CursorStart() - } - } -} - -// characterLeft moves the cursor one character to the left. -// If insideLine is set, the cursor is moved to the last -// character in the previous line, instead of one past that. -func (m *Model) characterLeft(insideLine bool) { - if m.col == 0 && m.row != 0 { - m.row-- - m.CursorEnd() - if !insideLine { - return - } - } - if m.col > 0 { - m.SetCursorColumn(m.col - 1) - } -} - -// wordLeft moves the cursor one word to the left. Returns whether or not the -// cursor blink should be reset. If input is masked, move input to the start -// so as not to reveal word breaks in the masked input. -func (m *Model) wordLeft() { - for { - m.characterLeft(true /* insideLine */) - if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) { - break - } - } - - for m.col > 0 { - if isSpaceAt(m.value[m.row], m.col-1) { - break - } - m.SetCursorColumn(m.col - 1) - } -} - -// wordRight moves the cursor one word to the right. Returns whether or not the -// cursor blink should be reset. If the input is masked, move input to the end -// so as not to reveal word breaks in the masked input. -func (m *Model) wordRight() { - m.doWordRight(func(int, int) { /* nothing */ }) -} - -func (m *Model) doWordRight(fn func(charIdx int, pos int)) { - // Skip spaces forward. - for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) { - if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { - // End of text. - break - } - m.characterRight() - } - - charIdx := 0 - for m.col < len(m.value[m.row]) { - if isSpaceAt(m.value[m.row], m.col) { - break - } - fn(charIdx, m.col) - m.SetCursorColumn(m.col + 1) - charIdx++ - } -} - -// uppercaseRight changes the word to the right to uppercase. -func (m *Model) uppercaseRight() { - m.doWordRight(func(_ int, i int) { - if r, ok := m.value[m.row][i].(rune); ok { - m.value[m.row][i] = unicode.ToUpper(r) - } - }) -} - -// lowercaseRight changes the word to the right to lowercase. -func (m *Model) lowercaseRight() { - m.doWordRight(func(_ int, i int) { - if r, ok := m.value[m.row][i].(rune); ok { - m.value[m.row][i] = unicode.ToLower(r) - } - }) -} - -// capitalizeRight changes the word to the right to title case. -func (m *Model) capitalizeRight() { - m.doWordRight(func(charIdx int, i int) { - if charIdx == 0 { - if r, ok := m.value[m.row][i].(rune); ok { - m.value[m.row][i] = unicode.ToTitle(r) - } - } - }) -} - -// LineInfo returns the number of characters from the start of the -// (soft-wrapped) line and the (soft-wrapped) line width. -func (m Model) LineInfo() LineInfo { - grid := m.memoizedWrap(m.value[m.row], m.width) - - // Find out which line we are currently on. This can be determined by the - // m.col and counting the number of runes that we need to skip. - var counter int - for i, line := range grid { - start := counter - end := counter + len(line) - - if m.col >= start && m.col <= end { - // This is the wrapped line the cursor is on. - - // Special case: if the cursor is at the end of a wrapped line, - // and there's another wrapped line after it, the cursor should - // be considered at the beginning of the next line. - if m.col == end && i < len(grid)-1 { - nextLine := grid[i+1] - return LineInfo{ - CharOffset: 0, - ColumnOffset: 0, - Height: len(grid), - RowOffset: i + 1, - StartColumn: end, - Width: len(nextLine), - CharWidth: uniseg.StringWidth(interfacesToString(nextLine)), - } - } - - return LineInfo{ - CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])), - ColumnOffset: m.col - start, - Height: len(grid), - RowOffset: i, - StartColumn: start, - Width: len(line), - CharWidth: uniseg.StringWidth(interfacesToString(line)), - } - } - counter = end - } - return LineInfo{} -} - -// Width returns the width of the textarea. -func (m Model) Width() int { - return m.width -} - -// MoveToBegin moves the cursor to the beginning of the input. -func (m *Model) MoveToBegin() { - m.row = 0 - m.SetCursorColumn(0) -} - -// MoveToEnd moves the cursor to the end of the input. -func (m *Model) MoveToEnd() { - m.row = len(m.value) - 1 - m.SetCursorColumn(len(m.value[m.row])) -} - -// SetWidth sets the width of the textarea to fit exactly within the given width. -// This means that the textarea will account for the width of the prompt and -// whether or not line numbers are being shown. -// -// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers, -// It is important that the width of the textarea be exactly the given width -// and no more. -func (m *Model) SetWidth(w int) { - // Update prompt width only if there is no prompt function as - // [SetPromptFunc] updates the prompt width when it is called. - if m.promptFunc == nil { - // XXX: Do we even need this or can we calculate the prompt width - // at render time? - m.promptWidth = uniseg.StringWidth(m.Prompt) - } - - // Add base style borders and padding to reserved outer width. - reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize() - - // Add prompt width to reserved inner width. - reservedInner := m.promptWidth - - // Add line number width to reserved inner width. - if m.ShowLineNumbers { - // XXX: this was originally documented as needing "1 cell" but was, - // in practice, effectively hardcoded to 2 cells. We can, and should, - // reduce this to one gap and update the tests accordingly. - const gap = 2 - - // Number of digits plus 1 cell for the margin. - reservedInner += numDigits(m.MaxHeight) + gap - } - - // Input width must be at least one more than the reserved inner and outer - // width. This gives us a minimum input width of 1. - minWidth := reservedInner + reservedOuter + 1 - inputWidth := max(w, minWidth) - - // Input width must be no more than maximum width. - if m.MaxWidth > 0 { - inputWidth = min(inputWidth, m.MaxWidth) - } - - // Since the width of the viewport and input area is dependent on the width of - // borders, prompt and line numbers, we need to calculate it by subtracting - // the reserved width from them. - - m.width = inputWidth - reservedOuter - reservedInner -} - -// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead. -// -// If the function returns a prompt that is shorter than the specified -// promptWidth, it will be padded to the left. If it returns a prompt that is -// longer, display artifacts may occur; the caller is responsible for computing -// an adequate promptWidth. -func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) { - m.promptFunc = fn - m.promptWidth = promptWidth -} - -// Height returns the current height of the textarea. -func (m Model) Height() int { - return m.height -} - -// ContentHeight returns the actual height needed to display all content -// including wrapped lines. -func (m Model) ContentHeight() int { - totalLines := 0 - for _, line := range m.value { - wrappedLines := m.memoizedWrap(line, m.width) - totalLines += len(wrappedLines) - } - // Ensure at least one line is shown - if totalLines == 0 { - totalLines = 1 - } - return totalLines -} - -// SetHeight sets the height of the textarea. -func (m *Model) SetHeight(h int) { - // Calculate the actual content height - contentHeight := m.ContentHeight() - - // Use the content height as the actual height - if m.MaxHeight > 0 { - m.height = clamp(contentHeight, minHeight, m.MaxHeight) - } else { - m.height = max(contentHeight, minHeight) - } -} - -// Update is the Bubble Tea update loop. -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - if !m.focus { - m.virtualCursor.Blur() - return m, nil - } - - // Used to determine if the cursor should blink. - oldRow, oldCol := m.cursorLineNumber(), m.col - - var cmds []tea.Cmd - - if m.row >= len(m.value) { - m.value = append(m.value, make([]any, 0)) - } - if m.value[m.row] == nil { - m.value[m.row] = make([]any, 0) - } - - if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() { - m.cache = NewMemoCache[line, [][]any](m.MaxHeight) - } - - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.KeyMap.DeleteAfterCursor): - m.col = clamp(m.col, 0, len(m.value[m.row])) - if m.col >= len(m.value[m.row]) { - m.mergeLineBelow(m.row) - break - } - m.deleteAfterCursor() - case key.Matches(msg, m.KeyMap.DeleteBeforeCursor): - m.col = clamp(m.col, 0, len(m.value[m.row])) - if m.col <= 0 { - m.mergeLineAbove(m.row) - break - } - m.deleteBeforeCursor() - case key.Matches(msg, m.KeyMap.DeleteCharacterBackward): - // If the cursor is at or just after an attachment, convert it to text instead of deleting - if att, _, _ := m.isAttachmentAtCursor(); att != nil { - if m.removeAttachmentAtCursor() { - break - } - } - m.col = clamp(m.col, 0, len(m.value[m.row])) - if m.col <= 0 { - m.mergeLineAbove(m.row) - break - } - if len(m.value[m.row]) > 0 && m.col > 0 { - m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) - m.SetCursorColumn(m.col - 1) - } - case key.Matches(msg, m.KeyMap.DeleteCharacterForward): - // If the cursor is on an attachment, convert it to text instead of deleting - if att, _, _ := m.isAttachmentAtCursor(); att != nil { - if m.removeAttachmentAtCursor() { - break - } - } - if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { - m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1) - } - if m.col >= len(m.value[m.row]) { - m.mergeLineBelow(m.row) - break - } - case key.Matches(msg, m.KeyMap.DeleteWordBackward): - if m.col <= 0 { - m.mergeLineAbove(m.row) - break - } - m.deleteWordLeft() - case key.Matches(msg, m.KeyMap.DeleteWordForward): - m.col = clamp(m.col, 0, len(m.value[m.row])) - if m.col >= len(m.value[m.row]) { - m.mergeLineBelow(m.row) - break - } - m.deleteWordRight() - case key.Matches(msg, m.KeyMap.InsertNewline): - m.Newline() - case key.Matches(msg, m.KeyMap.LineEnd): - m.CursorEnd() - case key.Matches(msg, m.KeyMap.LineStart): - m.CursorStart() - case key.Matches(msg, m.KeyMap.CharacterForward): - m.characterRight() - case key.Matches(msg, m.KeyMap.LineNext): - m.CursorDown() - case key.Matches(msg, m.KeyMap.WordForward): - m.wordRight() - case key.Matches(msg, m.KeyMap.CharacterBackward): - m.characterLeft(false /* insideLine */) - case key.Matches(msg, m.KeyMap.LinePrevious): - m.CursorUp() - case key.Matches(msg, m.KeyMap.WordBackward): - m.wordLeft() - case key.Matches(msg, m.KeyMap.InputBegin): - m.MoveToBegin() - case key.Matches(msg, m.KeyMap.InputEnd): - m.MoveToEnd() - case key.Matches(msg, m.KeyMap.LowercaseWordForward): - m.lowercaseRight() - case key.Matches(msg, m.KeyMap.UppercaseWordForward): - m.uppercaseRight() - case key.Matches(msg, m.KeyMap.CapitalizeWordForward): - m.capitalizeRight() - case key.Matches(msg, m.KeyMap.TransposeCharacterBackward): - m.transposeLeft() - - default: - m.InsertRunesFromUserInput([]rune(msg.Text)) - } - - case pasteMsg: - m.InsertRunesFromUserInput([]rune(msg)) - - case pasteErrMsg: - m.Err = msg - } - - var cmd tea.Cmd - newRow, newCol := m.cursorLineNumber(), m.col - m.virtualCursor, cmd = m.virtualCursor.Update(msg) - if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink { - m.virtualCursor.Blink = false - cmd = m.virtualCursor.BlinkCmd() - } - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -// View renders the text area in its current state. -func (m Model) View() string { - m.updateVirtualCursorStyle() - if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { - return m.placeholderView() - } - m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine() - - var ( - s strings.Builder - style lipgloss.Style - newLines int - widestLineNumber int - lineInfo = m.LineInfo() - styles = m.activeStyle() - ) - - displayLine := 0 - for l, line := range m.value { - wrappedLines := m.memoizedWrap(line, m.width) - - if m.row == l { - style = styles.computedCursorLine() - } else { - style = styles.computedText() - } - - for wl, wrappedLine := range wrappedLines { - prompt := m.promptView(displayLine) - prompt = styles.computedPrompt().Render(prompt) - s.WriteString(style.Render(prompt)) - displayLine++ - - var ln string - if m.ShowLineNumbers { - if wl == 0 { // normal line - isCursorLine := m.row == l - s.WriteString(m.lineNumberView(l+1, isCursorLine)) - } else { // soft wrapped line - isCursorLine := m.row == l - s.WriteString(m.lineNumberView(-1, isCursorLine)) - } - } - - // Note the widest line number for padding purposes later. - lnw := uniseg.StringWidth(ln) - if lnw > widestLineNumber { - widestLineNumber = lnw - } - - wrappedLineStr := interfacesToString(wrappedLine) - strwidth := uniseg.StringWidth(wrappedLineStr) - padding := m.width - strwidth - // If the trailing space causes the line to be wider than the - // width, we should not draw it to the screen since it will result - // in an extra space at the end of the line which can look off when - // the cursor line is showing. - if strwidth > m.width { - // The character causing the line to be wider than the width is - // guaranteed to be a space since any other character would - // have been wrapped. - wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ") - padding = m.width - uniseg.StringWidth(wrappedLineStr) - } - - if m.row == l && lineInfo.RowOffset == wl { - // Render the part of the line before the cursor - s.WriteString( - m.renderLineWithAttachments( - wrappedLine[:lineInfo.ColumnOffset], - style, - ), - ) - - if m.col >= len(line) && lineInfo.CharOffset >= m.width { - m.virtualCursor.SetChar(" ") - s.WriteString(m.virtualCursor.View()) - } else if lineInfo.ColumnOffset < len(wrappedLine) { - // Render the item under the cursor - item := wrappedLine[lineInfo.ColumnOffset] - if att, ok := item.(*attachment.Attachment); ok { - // Item at cursor is an attachment. Render it with the selection style. - // This becomes the "cursor" visually. - s.WriteString(m.Styles.SelectedAttachment.Render(att.Display)) - } else { - // Item at cursor is a rune. Render it with the virtual cursor. - m.virtualCursor.SetChar(string(item.(rune))) - s.WriteString(style.Render(m.virtualCursor.View())) - } - - // Render the part of the line after the cursor - s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style)) - } else { - // Cursor is at the end of the line - m.virtualCursor.SetChar(" ") - s.WriteString(style.Render(m.virtualCursor.View())) - } - } else { - s.WriteString(m.renderLineWithAttachments(wrappedLine, style)) - } - - s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) - s.WriteRune('\n') - newLines++ - } - } - - // Remove the trailing newline from the last line - result := s.String() - if len(result) > 0 && result[len(result)-1] == '\n' { - result = result[:len(result)-1] - } - - return styles.Base.Render(result) -} - -// promptView renders a single line of the prompt. -func (m Model) promptView(displayLine int) (prompt string) { - prompt = m.Prompt - if m.promptFunc == nil { - return prompt - } - prompt = m.promptFunc(displayLine) - width := lipgloss.Width(prompt) - if width < m.promptWidth { - prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt) - } - - return m.activeStyle().computedPrompt().Render(prompt) -} - -// lineNumberView renders the line number. -// -// If the argument is less than 0, a space styled as a line number is returned -// instead. Such cases are used for soft-wrapped lines. -// -// The second argument indicates whether this line number is for a 'cursorline' -// line number. -func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { - if !m.ShowLineNumbers { - return "" - } - - if n <= 0 { - str = " " - } else { - str = strconv.Itoa(n) - } - - // XXX: is textStyle really necessary here? - textStyle := m.activeStyle().computedText() - lineNumberStyle := m.activeStyle().computedLineNumber() - if isCursorLine { - textStyle = m.activeStyle().computedCursorLine() - lineNumberStyle = m.activeStyle().computedCursorLineNumber() - } - - // Format line number dynamically based on the maximum number of lines. - digits := len(strconv.Itoa(m.MaxHeight)) - str = fmt.Sprintf(" %*v ", digits, str) - - return textStyle.Render(lineNumberStyle.Render(str)) -} - -// placeholderView returns the prompt and placeholder, if any. -func (m Model) placeholderView() string { - var ( - s strings.Builder - p = m.Placeholder - styles = m.activeStyle() - ) - // word wrap lines - pwordwrap := ansi.Wordwrap(p, m.width, "") - // hard wrap lines (handles lines that could not be word wrapped) - pwrap := ansi.Hardwrap(pwordwrap, m.width, true) - // split string by new lines - plines := strings.Split(strings.TrimSpace(pwrap), "\n") - - // Only render the actual placeholder lines, not padded to m.height - maxLines := max(len(plines), 1) // At least show one line for cursor - for i := range maxLines { - isLineNumber := len(plines) > i - - lineStyle := styles.computedPlaceholder() - if len(plines) > i { - lineStyle = styles.computedCursorLine() - } - - // render prompt - prompt := m.promptView(i) - prompt = styles.computedPrompt().Render(prompt) - s.WriteString(lineStyle.Render(prompt)) - - // when show line numbers enabled: - // - render line number for only the cursor line - // - indent other placeholder lines - // this is consistent with vim with line numbers enabled - if m.ShowLineNumbers { - var ln int - - switch { - case i == 0: - ln = i + 1 - fallthrough - case len(plines) > i: - s.WriteString(m.lineNumberView(ln, isLineNumber)) - default: - } - } - - switch { - // first line - case i == 0: - // first character of first line as cursor with character - m.virtualCursor.TextStyle = styles.computedPlaceholder() - m.virtualCursor.SetChar(string(plines[0][0])) - s.WriteString(lineStyle.Render(m.virtualCursor.View())) - - // the rest of the first line - placeholderTail := plines[0][1:] - gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))) - renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap) - s.WriteString(lineStyle.Render(renderedPlaceholder)) - // remaining lines - case len(plines) > i: - // current line placeholder text - if len(plines) > i { - placeholderLine := plines[i] - gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i]))) - s.WriteString(lineStyle.Render(placeholderLine + gap)) - } - default: - // end of line buffer character - eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) - s.WriteString(eob) - } - - // terminate with new line (except for last line) - if i < maxLines-1 { - s.WriteRune('\n') - } - } - - return styles.Base.Render(s.String()) -} - -// Blink returns the blink command for the virtual cursor. -func Blink() tea.Msg { - return cursor.Blink() -} - -// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea -// program. This requires that [Model.VirtualCursor] is set to false. -// -// Note that you will almost certainly also need to adjust the offset cursor -// position per the textarea's per the textarea's position in the terminal. -// -// Example: -// -// // In your top-level View function: -// f := tea.NewFrame(m.textarea.View()) -// f.Cursor = m.textarea.Cursor() -// f.Cursor.Position.X += offsetX -// f.Cursor.Position.Y += offsetY -func (m Model) Cursor() *tea.Cursor { - if m.VirtualCursor { - return nil - } - - lineInfo := m.LineInfo() - w := lipgloss.Width - baseStyle := m.activeStyle().Base - - xOffset := lineInfo.CharOffset + - w(m.promptView(0)) + - w(m.lineNumberView(0, false)) + - baseStyle.GetMarginLeft() + - baseStyle.GetPaddingLeft() + - baseStyle.GetBorderLeftSize() - - yOffset := m.cursorLineNumber() - - baseStyle.GetMarginTop() + - baseStyle.GetPaddingTop() + - baseStyle.GetBorderTopSize() - - c := tea.NewCursor(xOffset, yOffset) - c.Blink = m.Styles.Cursor.Blink - c.Color = m.Styles.Cursor.Color - c.Shape = m.Styles.Cursor.Shape - return c -} - -func (m Model) memoizedWrap(content []any, width int) [][]any { - input := line{content: content, width: width} - if v, ok := m.cache.Get(input); ok { - return v - } - v := wrapInterfaces(content, width) - m.cache.Set(input, v) - return v -} - -// cursorLineNumber returns the line number that the cursor is on. -// This accounts for soft wrapped lines. -func (m Model) cursorLineNumber() int { - line := 0 - for i := range m.row { - // Calculate the number of lines that the current line will be split - // into. - line += len(m.memoizedWrap(m.value[i], m.width)) - } - line += m.LineInfo().RowOffset - return line -} - -// mergeLineBelow merges the current line the cursor is on with the line below. -func (m *Model) mergeLineBelow(row int) { - if row >= len(m.value)-1 { - return - } - - // To perform a merge, we will need to combine the two lines and then - m.value[row] = append(m.value[row], m.value[row+1]...) - - // Shift all lines up by one - for i := row + 1; i < len(m.value)-1; i++ { - m.value[i] = m.value[i+1] - } - - // And, remove the last line - if len(m.value) > 0 { - m.value = m.value[:len(m.value)-1] - } -} - -// mergeLineAbove merges the current line the cursor is on with the line above. -func (m *Model) mergeLineAbove(row int) { - if row <= 0 { - return - } - - m.col = len(m.value[row-1]) - m.row = m.row - 1 - - // To perform a merge, we will need to combine the two lines and then - m.value[row-1] = append(m.value[row-1], m.value[row]...) - - // Shift all lines up by one - for i := row; i < len(m.value)-1; i++ { - m.value[i] = m.value[i+1] - } - - // And, remove the last line - if len(m.value) > 0 { - m.value = m.value[:len(m.value)-1] - } -} - -func (m *Model) splitLine(row, col int) { - // To perform a split, take the current line and keep the content before - // the cursor, take the content after the cursor and make it the content of - // the line underneath, and shift the remaining lines down by one - head, tailSrc := m.value[row][:col], m.value[row][col:] - tail := copyInterfaceSlice(tailSrc) - - m.value = append(m.value[:row+1], m.value[row:]...) - - m.value[row] = head - m.value[row+1] = tail - - m.col = 0 - m.row++ -} - -func itemWidth(item any) int { - switch v := item.(type) { - case rune: - return rw.RuneWidth(v) - case *attachment.Attachment: - return uniseg.StringWidth(v.Display) - } - return 0 -} - -// forceWrapAttachment splits an attachment's display text across multiple lines -func forceWrapAttachment(att *attachment.Attachment, width int) [][]any { - if width <= 0 { - return [][]any{{att}} - } - - display := att.Display - displayRunes := []rune(display) - - if len(displayRunes) <= width { - return [][]any{{att}} - } - - var lines [][]any - start := 0 - - for start < len(displayRunes) { - // Calculate how many runes fit in this line - end := start + width - if end > len(displayRunes) { - end = len(displayRunes) - } - - // Create a wrapped attachment for this segment - wrappedAtt := &attachment.Attachment{ - ID: att.ID, - Type: att.Type, - Display: string(displayRunes[start:end]), - URL: att.URL, - Filename: att.Filename, - MediaType: att.MediaType, - Source: att.Source, - } - - lines = append(lines, []any{wrappedAtt}) - start = end - } - - return lines -} - -// forceWrapWord splits a word that's too long to fit within the given width -func forceWrapWord(word []any, width int) [][]any { - if width <= 0 || len(word) == 0 { - return [][]any{word} - } - - var lines [][]any - currentLine := []any{} - currentWidth := 0 - - for _, item := range word { - if att, ok := item.(*attachment.Attachment); ok { - // Handle attachment that might be too wide - attWidth := uniseg.StringWidth(att.Display) - - // If the attachment display is too wide, split it - if attWidth > width { - // Finish current line if it has content - if len(currentLine) > 0 { - lines = append(lines, currentLine) - currentLine = []any{} - currentWidth = 0 - } - - // Split the attachment display across multiple lines - wrappedAttachment := forceWrapAttachment(att, width) - lines = append(lines, wrappedAttachment...) - continue - } - - // If adding this attachment would exceed the width, start a new line - if currentWidth+attWidth > width && len(currentLine) > 0 { - lines = append(lines, currentLine) - currentLine = []any{} - currentWidth = 0 - } - - currentLine = append(currentLine, item) - currentWidth += attWidth - } else if r, ok := item.(rune); ok { - itemWidth := rw.RuneWidth(r) - - // If adding this rune would exceed the width, start a new line - if currentWidth+itemWidth > width && len(currentLine) > 0 { - lines = append(lines, currentLine) - currentLine = []any{} - currentWidth = 0 - } - - currentLine = append(currentLine, item) - currentWidth += itemWidth - } - } - - // Add the last line if it has content - if len(currentLine) > 0 { - lines = append(lines, currentLine) - } - - return lines -} - -func wrapInterfaces(content []any, width int) [][]any { - if width <= 0 { - return [][]any{content} - } - - var ( - lines = [][]any{{}} - word = []any{} - wordW int - lineW int - spaceW int - inSpaces bool - ) - - for _, item := range content { - itemW := 0 - isSpace := false - - if r, ok := item.(rune); ok { - if unicode.IsSpace(r) { - isSpace = true - } - itemW = rw.RuneWidth(r) - } else if att, ok := item.(*attachment.Attachment); ok { - itemW = uniseg.StringWidth(att.Display) - } - - if isSpace { - if !inSpaces { - // End of a word - if lineW > 0 && lineW+wordW > width { - // If the word itself is too long to fit on a line, force-wrap it - if wordW > width { - wrappedLines := forceWrapWord(word, width) - lines = append(lines, wrappedLines...) - // Calculate width of the last wrapped line - lastLine := wrappedLines[len(wrappedLines)-1] - lineW = 0 - for _, item := range lastLine { - if r, ok := item.(rune); ok { - lineW += rw.RuneWidth(r) - } else if att, ok := item.(*attachment.Attachment); ok { - lineW += uniseg.StringWidth(att.Display) - } - } - } else { - lines = append(lines, word) - lineW = wordW - } - } else { - // Check if the word needs to be force-wrapped even when it fits on the current line - if wordW > width { - currentLine := lines[len(lines)-1] - wrappedWord := forceWrapWord(word, width-lineW) - if len(wrappedWord) > 0 { - lines[len(lines)-1] = append(currentLine, wrappedWord[0]...) - for i := 1; i < len(wrappedWord); i++ { - lines = append(lines, wrappedWord[i]) - } - // Calculate width of the last wrapped line - lastLine := wrappedWord[len(wrappedWord)-1] - lineW = 0 - for _, item := range lastLine { - if r, ok := item.(rune); ok { - lineW += rw.RuneWidth(r) - } else if att, ok := item.(*attachment.Attachment); ok { - lineW += uniseg.StringWidth(att.Display) - } - } - } - } else { - lines[len(lines)-1] = append(lines[len(lines)-1], word...) - lineW += wordW - } - } - word = nil - wordW = 0 - } - inSpaces = true - spaceW += itemW - } else { // It's not a space, it's a character for a word. - if inSpaces { - // We just finished a block of spaces. Handle them now. - lineW += spaceW - for i := 0; i < spaceW; i++ { - lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) - } - if lineW > width { - // The spaces made the line overflow. Start a new line for the upcoming word. - lines = append(lines, []any{}) - lineW = 0 - } - spaceW = 0 - } - inSpaces = false - word = append(word, item) - wordW += itemW - } - } - - // Handle any remaining word/spaces at the end of the content. - if wordW > 0 { - if lineW > 0 && lineW+wordW > width { - // If the word itself is too long to fit on a line, force-wrap it - if wordW > width { - wrappedLines := forceWrapWord(word, width) - lines = append(lines, wrappedLines...) - // Calculate width of the last wrapped line - lastLine := wrappedLines[len(wrappedLines)-1] - lineW = 0 - for _, item := range lastLine { - if r, ok := item.(rune); ok { - lineW += rw.RuneWidth(r) - } else if att, ok := item.(*attachment.Attachment); ok { - lineW += uniseg.StringWidth(att.Display) - } - } - } else { - lines = append(lines, word) - lineW = wordW - } - } else { - // Check if the word needs to be force-wrapped even when it fits on the current line - if wordW > width { - currentLine := lines[len(lines)-1] - wrappedWord := forceWrapWord(word, width-lineW) - if len(wrappedWord) > 0 { - lines[len(lines)-1] = append(currentLine, wrappedWord[0]...) - for i := 1; i < len(wrappedWord); i++ { - lines = append(lines, wrappedWord[i]) - } - // Calculate width of the last wrapped line - lastLine := wrappedWord[len(wrappedWord)-1] - lineW = 0 - for _, item := range lastLine { - if r, ok := item.(rune); ok { - lineW += rw.RuneWidth(r) - } else if att, ok := item.(*attachment.Attachment); ok { - lineW += uniseg.StringWidth(att.Display) - } - } - } - } else { - lines[len(lines)-1] = append(lines[len(lines)-1], word...) - lineW += wordW - } - } - } - if spaceW > 0 { - // There are trailing spaces. Add them. - for i := 0; i < spaceW; i++ { - lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) - lineW += 1 - } - if lineW > width { - lines = append(lines, []any{}) - } - } - - return lines -} - -func repeatSpaces(n int) []rune { - return []rune(strings.Repeat(string(' '), n)) -} - -// numDigits returns the number of digits in an integer. -func numDigits(n int) int { - if n == 0 { - return 1 - } - count := 0 - num := abs(n) - for num > 0 { - count++ - num /= 10 - } - return count -} - -func clamp(v, low, high int) int { - if high < low { - low, high = high, low - } - return min(high, max(low, v)) -} - -func abs(n int) int { - if n < 0 { - return -n - } - return n -} diff --git a/packages/tui/internal/components/textarea/textarea_test.go b/packages/tui/internal/components/textarea/textarea_test.go deleted file mode 100644 index fb3c5b8ba..000000000 --- a/packages/tui/internal/components/textarea/textarea_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package textarea - -import ( - "testing" - - "github.com/sst/opencode/internal/attachment" -) - -func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) { - m := New() - m.InsertString("a ") - att := &attachment.Attachment{ID: "1", Display: "@file.txt"} - m.InsertAttachment(att) - m.InsertString(" b") - - // Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b') - m.SetCursorColumn(3) - - if ok := m.removeAttachmentAtCursor(); !ok { - t.Fatalf("expected removal to occur") - } - got := m.Value() - want := "a @file.txt b" - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - -func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) { - m := New() - m.InsertString("x ") - att := &attachment.Attachment{ID: "2", Display: "@img.png"} - m.InsertAttachment(att) - m.InsertString(" y") - - // Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y') - m.SetCursorColumn(2) - - if ok := m.removeAttachmentAtCursor(); !ok { - t.Fatalf("expected removal to occur") - } - got := m.Value() - want := "x @img.png y" - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - -func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) { - m := New() - att := &attachment.Attachment{ID: "3", Display: "@a.txt"} - m.InsertAttachment(att) - m.InsertString(" tail") - - // Position cursor immediately after the attachment at start of line (index 1) - m.SetCursorColumn(1) - if ok := m.removeAttachmentAtCursor(); !ok { - t.Fatalf("expected removal to occur at start of line") - } - if got := m.Value(); got != "@a.txt tail" { - t.Fatalf("unexpected value: %q", got) - } -} - -func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) { - m := New() - m.InsertString("hello world") - col := m.CursorColumn() - if ok := m.removeAttachmentAtCursor(); ok { - t.Fatalf("did not expect removal to occur") - } - if m.Value() != "hello world" || m.CursorColumn() != col { - t.Fatalf("value or cursor unexpectedly changed") - } -} diff --git a/packages/tui/internal/components/toast/toast.go b/packages/tui/internal/components/toast/toast.go deleted file mode 100644 index 2de6bf619..000000000 --- a/packages/tui/internal/components/toast/toast.go +++ /dev/null @@ -1,266 +0,0 @@ -package toast - -import ( - "fmt" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" -) - -// ShowToastMsg is a message to display a toast notification -type ShowToastMsg struct { - Message string - Title *string - Color compat.AdaptiveColor - Duration time.Duration -} - -// DismissToastMsg is a message to dismiss a specific toast -type DismissToastMsg struct { - ID string -} - -// Toast represents a single toast notification -type Toast struct { - ID string - Message string - Title *string - Color compat.AdaptiveColor - CreatedAt time.Time - Duration time.Duration -} - -// ToastManager manages multiple toast notifications -type ToastManager struct { - toasts []Toast -} - -// NewToastManager creates a new toast manager -func NewToastManager() *ToastManager { - return &ToastManager{ - toasts: []Toast{}, - } -} - -// Init initializes the toast manager -func (tm *ToastManager) Init() tea.Cmd { - return nil -} - -// Update handles messages for the toast manager -func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) { - switch msg := msg.(type) { - case ShowToastMsg: - toast := Toast{ - ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()), - Title: msg.Title, - Message: msg.Message, - Color: msg.Color, - CreatedAt: time.Now(), - Duration: msg.Duration, - } - - tm.toasts = append(tm.toasts, toast) - - // Return command to dismiss after duration - return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg { - return DismissToastMsg{ID: toast.ID} - }) - - case DismissToastMsg: - var newToasts []Toast - for _, t := range tm.toasts { - if t.ID != msg.ID { - newToasts = append(newToasts, t) - } - } - tm.toasts = newToasts - } - - return tm, nil -} - -// renderSingleToast renders a single toast notification -func (tm *ToastManager) renderSingleToast(toast Toast) string { - t := theme.CurrentTheme() - - baseStyle := styles.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundElement()). - Padding(1, 2) - - maxWidth := max(40, layout.Current.Viewport.Width/3) - contentMaxWidth := max(maxWidth-6, 20) - - // Build content with wrapping - var content strings.Builder - if toast.Title != nil { - titleStyle := styles.NewStyle().Foreground(toast.Color). - Bold(true) - content.WriteString(titleStyle.Render(*toast.Title)) - content.WriteString("\n") - } - - // Wrap message text - messageStyle := styles.NewStyle() - contentWidth := lipgloss.Width(toast.Message) - if contentWidth > contentMaxWidth { - messageStyle = messageStyle.Width(contentMaxWidth) - } - content.WriteString(messageStyle.Render(toast.Message)) - - // Render toast with max width - return baseStyle.MaxWidth(maxWidth).Render(content.String()) -} - -// View renders all active toasts -func (tm *ToastManager) View() string { - if len(tm.toasts) == 0 { - return "" - } - - var toastViews []string - for _, toast := range tm.toasts { - toastView := tm.renderSingleToast(toast) - toastViews = append(toastViews, toastView+"\n") - } - - return strings.Join(toastViews, "\n") -} - -// RenderOverlay renders the toasts as an overlay on the given background -func (tm *ToastManager) RenderOverlay(background string) string { - if len(tm.toasts) == 0 { - return background - } - - bgWidth := lipgloss.Width(background) - bgHeight := lipgloss.Height(background) - result := background - - // Start from top with 2 character padding - currentY := 2 - - // Render each toast individually - for _, toast := range tm.toasts { - // Render individual toast - toastView := tm.renderSingleToast(toast) - toastWidth := lipgloss.Width(toastView) - toastHeight := lipgloss.Height(toastView) - - // Position at top-right with 2 character padding from right edge - x := max(bgWidth-toastWidth-4, 0) - - // Check if toast fits vertically - if currentY+toastHeight > bgHeight-2 { - // No more room for toasts - break - } - - // Place this toast - result = layout.PlaceOverlay( - x, - currentY, - toastView, - result, - layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(toast.Color), - ) - - // Move down for next toast (add 1 for spacing between toasts) - currentY += toastHeight + 1 - } - - return result -} - -type ToastOptions struct { - Title string - Duration time.Duration -} - -type toastOptions struct { - title *string - duration *time.Duration - color *compat.AdaptiveColor -} - -type ToastOption func(*toastOptions) - -func WithTitle(title string) ToastOption { - return func(t *toastOptions) { - t.title = &title - } -} -func WithDuration(duration time.Duration) ToastOption { - return func(t *toastOptions) { - t.duration = &duration - } -} - -func WithColor(color compat.AdaptiveColor) ToastOption { - return func(t *toastOptions) { - t.color = &color - } -} - -func NewToast(message string, options ...ToastOption) tea.Cmd { - t := theme.CurrentTheme() - duration := 5 * time.Second - color := t.Primary() - - opts := toastOptions{ - duration: &duration, - color: &color, - } - for _, option := range options { - option(&opts) - } - - return func() tea.Msg { - return ShowToastMsg{ - Message: message, - Title: opts.title, - Duration: *opts.duration, - Color: *opts.color, - } - } -} - -func NewInfoToast(message string, options ...ToastOption) tea.Cmd { - options = append(options, WithColor(theme.CurrentTheme().Info())) - return NewToast( - message, - options..., - ) -} - -func NewSuccessToast(message string, options ...ToastOption) tea.Cmd { - options = append(options, WithColor(theme.CurrentTheme().Success())) - return NewToast( - message, - options..., - ) -} - -func NewWarningToast(message string, options ...ToastOption) tea.Cmd { - options = append(options, WithColor(theme.CurrentTheme().Warning())) - return NewToast( - message, - options..., - ) -} - -func NewErrorToast(message string, options ...ToastOption) tea.Cmd { - options = append(options, WithColor(theme.CurrentTheme().Error())) - return NewToast( - message, - options..., - ) -} |
