summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tui/internal/components')
-rw-r--r--packages/tui/internal/components/chat/cache.go62
-rw-r--r--packages/tui/internal/components/chat/editor.go906
-rw-r--r--packages/tui/internal/components/chat/message.go1031
-rw-r--r--packages/tui/internal/components/chat/messages.go1322
-rw-r--r--packages/tui/internal/components/commands/commands.go247
-rw-r--r--packages/tui/internal/components/dialog/agents.go452
-rw-r--r--packages/tui/internal/components/dialog/complete.go314
-rw-r--r--packages/tui/internal/components/dialog/help.go80
-rw-r--r--packages/tui/internal/components/dialog/models.go458
-rw-r--r--packages/tui/internal/components/dialog/search.go255
-rw-r--r--packages/tui/internal/components/dialog/session.go400
-rw-r--r--packages/tui/internal/components/dialog/theme.go132
-rw-r--r--packages/tui/internal/components/dialog/timeline.go353
-rw-r--r--packages/tui/internal/components/diff/diff.go957
-rw-r--r--packages/tui/internal/components/diff/parse.go58
-rw-r--r--packages/tui/internal/components/list/list.go436
-rw-r--r--packages/tui/internal/components/list/list_test.go249
-rw-r--r--packages/tui/internal/components/modal/modal.go145
-rw-r--r--packages/tui/internal/components/qr/qr.go56
-rw-r--r--packages/tui/internal/components/status/status.go340
-rw-r--r--packages/tui/internal/components/status/status_test.go100
-rw-r--r--packages/tui/internal/components/textarea/memoization.go125
-rw-r--r--packages/tui/internal/components/textarea/runeutil.go102
-rw-r--r--packages/tui/internal/components/textarea/textarea.go2377
-rw-r--r--packages/tui/internal/components/textarea/textarea_test.go75
-rw-r--r--packages/tui/internal/components/toast/toast.go266
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...,
- )
-}