summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-05 15:44:20 -0500
committeradamdottv <[email protected]>2025-06-11 11:43:28 -0500
commit95d5e1f2318e0c62f19196122fc2a448f1114cfd (patch)
tree75369872d32e10896e9263ddbf32cf36e7e418ac /packages/tui/internal/components
parent979bad3e64e3fff43d41094a79c73deb31e82ec8 (diff)
downloadopencode-95d5e1f2318e0c62f19196122fc2a448f1114cfd.tar.gz
opencode-95d5e1f2318e0c62f19196122fc2a448f1114cfd.zip
wip: refactoring tui
Diffstat (limited to 'packages/tui/internal/components')
-rw-r--r--packages/tui/internal/components/chat/cache.go36
-rw-r--r--packages/tui/internal/components/chat/chat.go109
-rw-r--r--packages/tui/internal/components/chat/editor.go254
-rw-r--r--packages/tui/internal/components/chat/message.go490
-rw-r--r--packages/tui/internal/components/chat/messages.go393
-rw-r--r--packages/tui/internal/components/chat/sidebar.go212
-rw-r--r--packages/tui/internal/components/core/status.go172
-rw-r--r--packages/tui/internal/components/dialog/custom_commands.go2
-rw-r--r--packages/tui/internal/components/diff/diff.go35
-rw-r--r--packages/tui/internal/components/spinner/spinner.go127
-rw-r--r--packages/tui/internal/components/spinner/spinner_test.go24
11 files changed, 821 insertions, 1033 deletions
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go
index 5219e7092..1586c2cc3 100644
--- a/packages/tui/internal/components/chat/cache.go
+++ b/packages/tui/internal/components/chat/cache.go
@@ -5,8 +5,6 @@ import (
"encoding/hex"
"fmt"
"sync"
-
- "github.com/sst/opencode/pkg/client"
)
// MessageCache caches rendered messages to avoid re-rendering
@@ -23,51 +21,27 @@ func NewMessageCache() *MessageCache {
}
// generateKey creates a unique key for a message based on its content and rendering parameters
-func (c *MessageCache) generateKey(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) string {
- // Create a hash of the message content and rendering parameters
+func (c *MessageCache) GenerateKey(params ...any) string {
h := sha256.New()
-
- // Include message ID and role
- h.Write(fmt.Appendf(nil, "%s:%s", msg.Id, msg.Role))
-
- // Include timestamp
- h.Write(fmt.Appendf(nil, ":%f", msg.Metadata.Time.Created))
-
- // Include width and showToolMessages flag
- h.Write(fmt.Appendf(nil, ":%d:%t", width, showToolMessages))
-
- // Include app path for relative path calculations
- h.Write([]byte(appInfo.Path.Root))
-
- // Include message parts
- for _, part := range msg.Parts {
- h.Write(fmt.Appendf(nil, ":%v", part))
- }
-
- // Include tool metadata if present
- for toolID, metadata := range msg.Metadata.Tool {
- h.Write(fmt.Appendf(nil, ":%s:%v", toolID, metadata))
+ 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 *MessageCache) Get(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) (string, bool) {
+func (c *MessageCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
- key := c.generateKey(msg, width, showToolMessages, appInfo)
content, exists := c.cache[key]
return content, exists
}
// Set stores a rendered message in the cache
-func (c *MessageCache) Set(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo, content string) {
+func (c *MessageCache) Set(key string, content string) {
c.mu.Lock()
defer c.mu.Unlock()
-
- key := c.generateKey(msg, width, showToolMessages, appInfo)
c.cache[key] = content
}
diff --git a/packages/tui/internal/components/chat/chat.go b/packages/tui/internal/components/chat/chat.go
index ad06728d3..29487efb7 100644
--- a/packages/tui/internal/components/chat/chat.go
+++ b/packages/tui/internal/components/chat/chat.go
@@ -1,11 +1,6 @@
package chat
import (
- "fmt"
- "sort"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -16,100 +11,6 @@ type SendMsg struct {
Attachments []app.Attachment
}
-func header(app *app.App, width int) string {
- return lipgloss.JoinVertical(
- lipgloss.Top,
- logo(width),
- repo(width),
- "",
- cwd(app, width),
- )
-}
-
-func lspsConfigured(width int) string {
- // cfg := config.Get()
- title := "LSP Servers"
- title = ansi.Truncate(title, width, "…")
-
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- lsps := baseStyle.
- Width(width).
- Foreground(t.Primary()).
- Bold(true).
- Render(title)
-
- // Get LSP names and sort them for consistent ordering
- var lspNames []string
- // for name := range cfg.LSP {
- // lspNames = append(lspNames, name)
- // }
- sort.Strings(lspNames)
-
- var lspViews []string
- // for _, name := range lspNames {
- // lsp := cfg.LSP[name]
- // lspName := baseStyle.
- // Foreground(t.Text()).
- // Render(fmt.Sprintf("• %s", name))
-
- // cmd := lsp.Command
- // cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
-
- // lspPath := baseStyle.
- // Foreground(t.TextMuted()).
- // Render(fmt.Sprintf(" (%s)", cmd))
-
- // lspViews = append(lspViews,
- // baseStyle.
- // Width(width).
- // Render(
- // lipgloss.JoinHorizontal(
- // lipgloss.Left,
- // lspName,
- // lspPath,
- // ),
- // ),
- // )
- // }
-
- return baseStyle.
- Width(width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lsps,
- lipgloss.JoinVertical(
- lipgloss.Left,
- lspViews...,
- ),
- ),
- )
-}
-
-func logo(width int) string {
- logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- versionText := baseStyle.
- Foreground(t.TextMuted()).
- Render("v0.0.1") // TODO: get version from server
-
- return baseStyle.
- Bold(true).
- Width(width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- logo,
- " ",
- versionText,
- ),
- )
-}
-
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
@@ -119,13 +20,3 @@ func repo(width int) string {
Width(width).
Render(repo)
}
-
-func cwd(app *app.App, width int) string {
- cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
- t := theme.CurrentTheme()
-
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Width(width).
- Render(cwd)
-}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 305365a25..52f198492 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -10,6 +10,7 @@ import (
"unicode"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -23,7 +24,7 @@ import (
"github.com/sst/opencode/internal/util"
)
-type editorCmp struct {
+type editorComponent struct {
width int
height int
app *app.App
@@ -33,6 +34,7 @@ type editorCmp struct {
history []string
historyIndex int
currentMessage string
+ spinner spinner.Model
}
type EditorKeyMaps struct {
@@ -96,86 +98,19 @@ const (
maxAttachments = 5
)
-func (m *editorCmp) openEditor(value string) tea.Cmd {
- editor := os.Getenv("EDITOR")
- if editor == "" {
- editor = "nvim"
- }
-
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- tmpfile.WriteString(value)
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- tmpfile.Close()
- c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- return tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- if len(content) == 0 {
- status.Warn("Message is empty")
- return nil
- }
- os.Remove(tmpfile.Name())
- attachments := m.attachments
- m.attachments = nil
- return SendMsg{
- Text: string(content),
- Attachments: attachments,
- }
- })
-}
-
-func (m *editorCmp) Init() tea.Cmd {
- return textarea.Blink
-}
-
-func (m *editorCmp) send() tea.Cmd {
- value := m.textarea.Value()
- m.textarea.Reset()
- attachments := m.attachments
-
- // Save to history if not empty and not a duplicate of the last entry
- if value != "" {
- if len(m.history) == 0 || m.history[len(m.history)-1] != value {
- m.history = append(m.history, value)
- }
- m.historyIndex = len(m.history)
- m.currentMessage = ""
- }
-
- m.attachments = nil
- if value == "" {
- return nil
- }
- return tea.Batch(
- util.CmdHandler(SendMsg{
- Text: value,
- Attachments: attachments,
- }),
- )
+func (m *editorComponent) Init() tea.Cmd {
+ return tea.Batch(textarea.Blink, m.spinner.Tick)
}
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
- m.textarea = CreateTextArea(&m.textarea)
+ m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
@@ -296,47 +231,160 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.send()
}
}
-
}
+
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+
m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
+ cmds = append(cmds, cmd)
+
+ return m, tea.Batch(cmds...)
}
-func (m *editorCmp) View() string {
+func (m *editorComponent) View() string {
t := theme.CurrentTheme()
-
- // Style the prompt with theme colors
- style := lipgloss.NewStyle().
+ base := styles.BaseStyle().Render
+ muted := styles.Muted().Render
+ promptStyle := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
+ prompt := promptStyle.Render(">")
- if len(m.attachments) == 0 {
- return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+ textarea := lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ prompt,
+ m.textarea.View(),
+ )
+ textarea = styles.BaseStyle().
+ Width(m.width-2).
+ Border(lipgloss.NormalBorder(), true, true).
+ BorderForeground(t.Border()).
+ Render(textarea)
+
+ hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
+ if m.app.IsBusy() {
+ hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
+ }
+
+ model := ""
+ if m.app.Model != nil {
+ model = base(*m.app.Model.Name) + muted(" • /model")
}
- m.textarea.SetHeight(m.height - 1)
- return lipgloss.JoinVertical(lipgloss.Top,
- m.attachmentsContent(),
- lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
- m.textarea.View()),
+
+ space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
+ spacer := lipgloss.NewStyle().Width(space).Render("")
+
+ info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
+ info = styles.Padded().Render(info)
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Top,
+ // m.attachmentsContent(),
+ textarea,
+ info,
+ )
+
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ content,
+ t.Background(),
)
}
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
+func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
- m.textarea.SetWidth(width - 3) // account for the prompt and padding right
- m.textarea.SetHeight(height)
+ m.textarea.SetWidth(width - 5) // account for the prompt and padding right
+ m.textarea.SetHeight(height - 3) // account for info underneath
return nil
}
-func (m *editorCmp) GetSize() (int, int) {
- return m.textarea.Width(), m.textarea.Height()
+func (m *editorComponent) GetSize() (int, int) {
+ return m.width, m.height
}
-func (m *editorCmp) attachmentsContent() string {
- var styledAttachments []string
+func (m *editorComponent) BindingKeys() []key.Binding {
+ bindings := []key.Binding{}
+ bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
+ bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
+ return bindings
+}
+
+func (m *editorComponent) openEditor(value string) tea.Cmd {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "nvim"
+ }
+
+ tmpfile, err := os.CreateTemp("", "msg_*.md")
+ tmpfile.WriteString(value)
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ tmpfile.Close()
+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return tea.ExecProcess(c, func(err error) tea.Msg {
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ content, err := os.ReadFile(tmpfile.Name())
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ if len(content) == 0 {
+ status.Warn("Message is empty")
+ return nil
+ }
+ os.Remove(tmpfile.Name())
+ attachments := m.attachments
+ m.attachments = nil
+ return SendMsg{
+ Text: string(content),
+ Attachments: attachments,
+ }
+ })
+}
+
+func (m *editorComponent) send() tea.Cmd {
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ attachments := m.attachments
+
+ // Save to history if not empty and not a duplicate of the last entry
+ if value != "" {
+ if len(m.history) == 0 || m.history[len(m.history)-1] != value {
+ m.history = append(m.history, value)
+ }
+ m.historyIndex = len(m.history)
+ m.currentMessage = ""
+ }
+
+ m.attachments = nil
+ if value == "" {
+ return nil
+ }
+ return tea.Batch(
+ util.CmdHandler(SendMsg{
+ Text: value,
+ Attachments: attachments,
+ }),
+ )
+}
+
+func (m *editorComponent) attachmentsContent() string {
+ if len(m.attachments) == 0 {
+ return ""
+ }
+
t := theme.CurrentTheme()
+ var styledAttachments []string
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
@@ -357,20 +405,15 @@ func (m *editorCmp) attachmentsContent() string {
return content
}
-func (m *editorCmp) BindingKeys() []key.Binding {
- bindings := []key.Binding{}
- bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
- bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
- return bindings
-}
-
-func CreateTextArea(existing *textarea.Model) textarea.Model {
+func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
+ ta.Placeholder = "It's prompting time..."
+
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
@@ -394,13 +437,16 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta
}
-func NewEditorCmp(app *app.App) tea.Model {
- ta := CreateTextArea(nil)
- return &editorCmp{
+func NewEditorComponent(app *app.App) tea.Model {
+ s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
+ ta := createTextArea(nil)
+
+ return &editorComponent{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
+ spinner: s,
}
}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index c78dd8e2f..5b91427d7 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -2,13 +2,18 @@ package chat
import (
"fmt"
+ "log/slog"
"path/filepath"
+ "slices"
"strings"
"time"
+ "unicode"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
+ "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff"
+ "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
@@ -16,14 +21,12 @@ import (
"golang.org/x/text/language"
)
-const (
- maxResultHeight = 10
-)
-
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
+ content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
+
if len(lines) > 0 {
firstLine := lines[0]
cleaned := ansi.Strip(firstLine)
@@ -40,139 +43,204 @@ func toMarkdown(content string, width int) string {
}
}
}
- return strings.TrimSuffix(strings.Join(lines, "\n"), "\n")
+
+ content = strings.Join(lines, "\n")
+ return strings.TrimSuffix(content, "\n")
}
-func renderUserMessage(user string, msg client.MessageInfo, width int) string {
+type markdownRenderer struct {
+ align *lipgloss.Position
+ borderColor *lipgloss.AdaptiveColor
+ fullWidth bool
+ paddingTop int
+ paddingBottom int
+}
+
+type markdownRenderingOption func(*markdownRenderer)
+
+func WithFullWidth() markdownRenderingOption {
+ return func(c *markdownRenderer) {
+ c.fullWidth = true
+ }
+}
+
+func WithAlign(align lipgloss.Position) markdownRenderingOption {
+ return func(c *markdownRenderer) {
+ c.align = &align
+ }
+}
+
+func WithBorderColor(color lipgloss.AdaptiveColor) markdownRenderingOption {
+ return func(c *markdownRenderer) {
+ c.borderColor = &color
+ }
+}
+
+func WithPaddingTop(padding int) markdownRenderingOption {
+ return func(c *markdownRenderer) {
+ c.paddingTop = padding
+ }
+}
+
+func WithPaddingBottom(padding int) markdownRenderingOption {
+ return func(c *markdownRenderer) {
+ c.paddingBottom = padding
+ }
+}
+
+func renderMarkdown(content string, options ...markdownRenderingOption) string {
t := theme.CurrentTheme()
+ renderer := &markdownRenderer{
+ fullWidth: false,
+ }
+ for _, option := range options {
+ option(renderer)
+ }
+
style := styles.BaseStyle().
- PaddingLeft(1).
- BorderLeft(true).
+ PaddingTop(1).
+ PaddingBottom(1).
+ PaddingLeft(2).
+ PaddingRight(2).
+ Background(t.BackgroundSubtle()).
Foreground(t.TextMuted()).
- BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
- // var styledAttachments []string
- // attachmentStyles := baseStyle.
- // MarginLeft(1).
- // Background(t.TextMuted()).
- // Foreground(t.Text())
- // for _, attachment := range msg.BinaryContent() {
- // file := filepath.Base(attachment.Path)
- // var filename string
- // if len(file) > 10 {
- // filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
- // } else {
- // filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
- // }
- // styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
- // }
+ align := lipgloss.Left
+ if renderer.align != nil {
+ align = *renderer.align
+ }
- timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
- if time.Now().Format("02 Jan 2006") == timestamp[:11] {
- timestamp = timestamp[12:]
+ borderColor := t.BackgroundSubtle()
+ if renderer.borderColor != nil {
+ borderColor = *renderer.borderColor
}
- info := styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf("%s (%s)", user, timestamp))
-
- content := ""
- // if len(styledAttachments) > 0 {
- // attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
- // content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
- // } else {
- for _, p := range msg.Parts {
- part, err := p.ValueByDiscriminator()
- if err != nil {
- continue //TODO: handle error?
- }
- switch part.(type) {
- case client.MessagePartText:
- textPart := part.(client.MessagePartText)
- text := toMarkdown(textPart.Text, width)
- content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
- }
+ switch align {
+ case lipgloss.Left:
+ style = style.
+ BorderLeft(true).
+ BorderRight(true).
+ AlignHorizontal(align).
+ BorderLeftForeground(borderColor).
+ BorderLeftBackground(t.Background()).
+ BorderRightForeground(t.BackgroundSubtle()).
+ BorderRightBackground(t.Background())
+ case lipgloss.Right:
+ style = style.
+ BorderRight(true).
+ BorderLeft(true).
+ AlignHorizontal(align).
+ BorderRightForeground(borderColor).
+ BorderRightBackground(t.Background()).
+ BorderLeftForeground(t.BackgroundSubtle()).
+ BorderLeftBackground(t.Background())
}
- return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ content = styles.ForceReplaceBackgroundWithLipgloss(content, t.BackgroundSubtle())
+ if renderer.fullWidth {
+ style = style.Width(layout.Current.Container.Width - 2)
+ }
+ content = style.Render(content)
+ if renderer.paddingTop > 0 {
+ content = strings.Repeat("\n", renderer.paddingTop) + content
+ }
+ if renderer.paddingBottom > 0 {
+ content = content + strings.Repeat("\n", renderer.paddingBottom)
+ }
+ content = lipgloss.PlaceHorizontal(
+ layout.Current.Container.Width,
+ align,
+ content,
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ )
+ content = lipgloss.PlaceHorizontal(
+ layout.Current.Viewport.Width,
+ lipgloss.Center,
+ content,
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ )
+ return content
}
-func renderAssistantMessage(
- msg client.MessageInfo,
- width int,
- showToolMessages bool,
- appInfo client.AppInfo,
-) string {
+func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
- style := styles.BaseStyle().
- PaddingLeft(1).
- BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.Primary()).
- BorderStyle(lipgloss.ThickBorder())
- messages := []string{}
+ width := layout.Current.Container.Width
+ padding := 0
+ switch layout.Current.Size {
+ case layout.LayoutSizeSmall:
+ padding = 5
+ case layout.LayoutSizeNormal:
+ padding = 10
+ case layout.LayoutSizeLarge:
+ padding = 15
+ }
- timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
+ timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
+ // don't show the date if it's today
timestamp = timestamp[12:]
}
- modelName := msg.Metadata.Assistant.ModelID
info := styles.BaseStyle().
Foreground(t.TextMuted()).
- Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
-
- for _, p := range msg.Parts {
- part, err := p.ValueByDiscriminator()
- if err != nil {
- continue //TODO: handle error?
- }
+ Render(fmt.Sprintf("%s (%s)", author, timestamp))
- switch part.(type) {
- // case client.MessagePartReasoning:
- // reasoningPart := part.(client.MessagePartReasoning)
+ align := lipgloss.Left
+ switch message.Role {
+ case client.User:
+ align = lipgloss.Right
+ case client.Assistant:
+ align = lipgloss.Left
+ }
- case client.MessagePartText:
- textPart := part.(client.MessagePartText)
- text := toMarkdown(textPart.Text, width)
- content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
- message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- messages = append(messages, message)
+ textWidth := lipgloss.Width(text)
+ markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
+ content := toMarkdown(text, markdownWidth)
+ content = lipgloss.JoinVertical(align, content, info)
- case client.MessagePartToolInvocation:
- if !showToolMessages {
- continue
- }
+ switch message.Role {
+ case client.User:
+ return renderMarkdown(content,
+ WithAlign(lipgloss.Right),
+ WithBorderColor(t.Secondary()),
+ )
+ case client.Assistant:
+ return renderMarkdown(content,
+ WithAlign(lipgloss.Left),
+ WithBorderColor(t.Primary()),
+ )
+ }
+ return ""
+}
- toolInvocationPart := part.(client.MessagePartToolInvocation)
- toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
- var result *string
- resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
- if resultError == nil {
- result = &resultPart.Result
- }
- metadata := map[string]any{}
- if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
- metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
- }
- message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
- messages = append(messages, message)
- }
+func renderToolInvocation(
+ toolCall client.MessageToolInvocationToolCall,
+ result *string,
+ metadata map[string]any,
+ showResult bool,
+) string {
+ ignoredTools := []string{"opencode_todoread"}
+ if slices.Contains(ignoredTools, toolCall.ToolName) {
+ return ""
}
- return strings.Join(messages, "\n\n")
-}
+ padding := 1
+ outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
+ innerWidth := outerWidth - padding - 4 // -4 for the border and padding
-func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, appInfo client.AppInfo, width int) string {
t := theme.CurrentTheme()
- style := styles.BaseStyle().
+ style := styles.Muted().
+ Width(outerWidth).
+ PaddingLeft(padding).
BorderLeft(true).
- PaddingLeft(1).
- Foreground(t.TextMuted()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.BorderSubtle()).
BorderStyle(lipgloss.ThickBorder())
- toolName := renderToolName(toolCall.ToolName)
+ if toolCall.State == "partial-call" {
+ style = style.Foreground(t.TextMuted())
+ return style.Render(renderToolAction(toolCall.ToolName))
+ }
+
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
@@ -185,17 +253,20 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
firstKey = key
break
}
- toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey)
+ toolArgs = renderArgs(&toolArgsMap, firstKey)
}
}
- title := fmt.Sprintf("%s: %s", toolName, toolArgs)
- finished := result != nil
- body := styles.BaseStyle().Render("In progress...")
+ if len(toolArgsMap) == 0 {
+ slog.Debug("no args")
+ }
+
+ body := ""
+ finished := result != nil && *result != ""
if finished {
body = *result
}
- footer := ""
+ elapsed := ""
if metadata["time"] != nil {
timeMap := metadata["time"].(map[string]any)
start := timeMap["start"].(float64)
@@ -206,84 +277,54 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second))
}
- footer = styles.Muted().Render(fmt.Sprintf("%s", roundedDuration))
+ elapsed = styles.Muted().Render(roundedDuration.String())
}
+ title := ""
switch toolCall.ToolName {
+ case "opencode_read":
+ toolArgs = renderArgs(&toolArgsMap, "filePath")
+ title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
+ body = ""
+ filename := toolArgsMap["filePath"].(string)
+ if metadata["preview"] != nil {
+ body = metadata["preview"].(string)
+ body = renderFile(filename, body, WithTruncate(6))
+ }
case "opencode_edit":
filename := toolArgsMap["filePath"].(string)
- filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
- title = fmt.Sprintf("%s: %s", toolName, filename)
- if finished && metadata["diff"] != nil {
+ title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
+ if metadata["diff"] != nil {
patch := metadata["diff"].(string)
- formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
+ diffWidth := min(layout.Current.Viewport.Width, 120)
+ formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
body = strings.TrimSpace(formattedDiff)
- return style.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
+ body = lipgloss.Place(
+ layout.Current.Viewport.Width,
+ lipgloss.Height(body)+2,
+ lipgloss.Center,
+ lipgloss.Center,
body,
- styles.ForceReplaceBackgroundWithLipgloss(footer, t.Background()),
- ))
- }
- case "opencode_read":
- toolArgs = renderArgs(&toolArgsMap, appInfo, "filePath")
- title = fmt.Sprintf("%s: %s", toolName, toolArgs)
- filename := toolArgsMap["filePath"].(string)
- ext := filepath.Ext(filename)
- if ext == "" {
- ext = ""
- } else {
- ext = strings.ToLower(ext[1:])
- }
- if finished {
- if metadata["preview"] != nil {
- body = metadata["preview"].(string)
- }
- body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
- body = toMarkdown(body, width)
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ )
}
case "opencode_write":
filename := toolArgsMap["filePath"].(string)
- filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
- title = fmt.Sprintf("%s: %s", toolName, filename)
- ext := filepath.Ext(filename)
- if ext == "" {
- ext = ""
- } else {
- ext = strings.ToLower(ext[1:])
- }
+ title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
content := toolArgsMap["content"].(string)
- body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10))
- body = toMarkdown(body, width)
+ body = renderFile(filename, content)
case "opencode_bash":
- if finished && metadata["stdout"] != nil {
- description := toolArgsMap["description"].(string)
- title = fmt.Sprintf("%s: %s", toolName, description)
+ description := toolArgsMap["description"].(string)
+ title = fmt.Sprintf("Shell: %s %s", description, elapsed)
+ if metadata["stdout"] != nil {
command := toolArgsMap["command"].(string)
stdout := metadata["stdout"].(string)
- body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout)
- body = toMarkdown(body, width)
- }
- case "opencode_todoread":
- title = fmt.Sprintf("%s", toolName)
- if finished && metadata["todos"] != nil {
- body = ""
- todos := metadata["todos"].([]any)
- for _, todo := range todos {
- t := todo.(map[string]any)
- content := t["content"].(string)
- switch t["status"].(string) {
- case "completed":
- body += fmt.Sprintf("- [x] %s\n", content)
- // case "in-progress":
- // body += fmt.Sprintf("- [ ] _%s_\n", content)
- default:
- body += fmt.Sprintf("- [ ] %s\n", content)
- }
- }
- body = toMarkdown(body, width)
+ body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
+ body = toMarkdown(body, innerWidth)
+ body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
case "opencode_todowrite":
- title = fmt.Sprintf("%s", toolName)
+ title = fmt.Sprintf("Planning... %s", elapsed)
if finished && metadata["todos"] != nil {
body = ""
todos := metadata["todos"].([]any)
@@ -299,23 +340,35 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
- body = toMarkdown(body, width)
+ body = toMarkdown(body, innerWidth)
+ body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
default:
- body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
- body = toMarkdown(body, width)
+ toolName := renderToolName(toolCall.ToolName)
+ title = style.Render(fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed))
+ // return title
+
+ // toolName := renderToolName(toolCall.ToolName)
+ // title = fmt.Sprintf("%s: %s", toolName, toolArgs)
+ // body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
+ // body = toMarkdown(body, contentWidth)
}
if metadata["error"] != nil && metadata["message"] != nil {
- body = styles.BaseStyle().Foreground(t.Error()).Render(metadata["message"].(string))
+ body = styles.BaseStyle().
+ Width(outerWidth).
+ Foreground(t.Error()).
+ Render(metadata["message"].(string))
}
- content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
- body,
- footer,
- ))
- return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ content := style.Render(title)
+ content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
+ content = styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ if showResult && body != "" {
+ content += "\n" + body
+ }
+ return content
+ // return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderToolName(name string) string {
@@ -327,9 +380,9 @@ func renderToolName(name string) string {
case "opencode_webfetch":
return "Fetch"
case "opencode_todoread":
- return "Read TODOs"
+ return "Planning"
case "opencode_todowrite":
- return "Update TODOs"
+ return "Planning"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
@@ -339,6 +392,59 @@ func renderToolName(name string) string {
}
}
+type fileRenderer struct {
+ filename string
+ content string
+ height int
+}
+
+type fileRenderingOption func(*fileRenderer)
+
+func WithTruncate(height int) fileRenderingOption {
+ return func(c *fileRenderer) {
+ c.height = height
+ }
+}
+
+func renderFile(filename string, content string, options ...fileRenderingOption) string {
+ renderer := &fileRenderer{
+ filename: filename,
+ content: content,
+ }
+ for _, option := range options {
+ option(renderer)
+ }
+
+ // TODO: is this even needed?
+ lines := []string{}
+ for line := range strings.SplitSeq(content, "\n") {
+ line = strings.TrimRightFunc(line, unicode.IsSpace)
+ line = strings.ReplaceAll(line, "\t", " ")
+ lines = append(lines, line)
+ }
+ content = strings.Join(lines, "\n")
+
+ width := layout.Current.Container.Width - 6
+ if renderer.height > 0 {
+ content = truncateHeight(content, renderer.height)
+ }
+ content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
+ content = toMarkdown(content, width)
+
+ // ensure no line is wider than the width
+ // truncated := []string{}
+ // for line := range strings.SplitSeq(content, "\n") {
+ // line = strings.TrimRightFunc(line, unicode.IsSpace)
+ // // if lipgloss.Width(line) > width-3 {
+ // line = ansi.Truncate(line, width-3, "")
+ // // }
+ // truncated = append(truncated, line)
+ // }
+ // content = strings.Join(truncated, "\n")
+
+ return renderMarkdown(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
+}
+
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
@@ -367,7 +473,7 @@ func renderToolAction(name string) string {
return "Working..."
}
-func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) string {
+func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
}
@@ -375,7 +481,7 @@ func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) s
parts := []string{}
for key, value := range *args {
if key == "filePath" || key == "path" {
- value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/")
+ value = relative(value.(string))
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
@@ -396,3 +502,17 @@ func truncateHeight(content string, height int) string {
}
return content
}
+
+func relative(path string) string {
+ return strings.TrimPrefix(path, app.Info.Path.Root+"/")
+}
+
+func extension(path string) string {
+ ext := filepath.Ext(path)
+ if ext == "" {
+ ext = ""
+ } else {
+ ext = strings.ToLower(ext[1:])
+ }
+ return ext
+}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index f39884e7a..093c8cf94 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -1,7 +1,7 @@
package chat
import (
- "fmt"
+ "strings"
"time"
"github.com/charmbracelet/bubbles/key"
@@ -11,21 +11,23 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
+ "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
-type messagesCmp struct {
- app *app.App
- width, height int
- viewport viewport.Model
- spinner spinner.Model
- rendering bool
- attachments viewport.Model
- showToolMessages bool
- cache *MessageCache
+type messagesComponent struct {
+ app *app.App
+ width, height int
+ viewport viewport.Model
+ spinner spinner.Model
+ rendering bool
+ attachments viewport.Model
+ showToolResults bool
+ cache *MessageCache
+ tail bool
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
@@ -56,44 +58,54 @@ var messageKeys = MessageKeys{
),
}
-func (m *messagesCmp) Init() tea.Cmd {
+func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
}
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
+ case SendMsg:
+ m.viewport.GotoBottom()
+ m.tail = true
+ return m, nil
case dialog.ThemeChangedMsg:
m.cache.Clear()
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
- m.showToolMessages = !m.showToolMessages
+ m.showToolResults = !m.showToolResults
m.renderView()
return m, nil
case state.SessionSelectedMsg:
- // Clear cache when switching sessions
m.cache.Clear()
cmd := m.Reload()
+ m.viewport.GotoBottom()
return m, cmd
case state.SessionClearedMsg:
- // Clear cache when session is cleared
m.cache.Clear()
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
- if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
- key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
+ if key.Matches(msg, messageKeys.PageUp) ||
+ key.Matches(msg, messageKeys.PageDown) ||
+ key.Matches(msg, messageKeys.HalfPageUp) ||
+ key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
+ m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
- m.viewport.GotoBottom()
+ if m.tail {
+ m.viewport.GotoBottom()
+ }
case state.StateUpdatedMsg:
m.renderView()
- m.viewport.GotoBottom()
+ if m.tail {
+ m.viewport.GotoBottom()
+ }
}
spinner, cmd := m.spinner.Update(msg)
@@ -102,91 +114,159 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
-func (m *messagesCmp) renderView() {
+type blockType int
+
+const (
+ none blockType = iota
+ systemTextBlock
+ userTextBlock
+ assistantTextBlock
+ toolInvocationBlock
+)
+
+func (m *messagesComponent) renderView() {
if m.width == 0 {
return
}
- messages := make([]string, 0)
- for _, msg := range m.app.Messages {
+ blocks := make([]string, 0)
+ previousBlockType := none
+ for _, message := range m.app.Messages {
+ if message.Role == client.System {
+ continue // ignoring system messages for now
+ }
+
var content string
var cached bool
- switch msg.Role {
+ author := ""
+ switch message.Role {
case client.User:
- content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
- if !cached {
- content = renderUserMessage(m.app.Info.User, msg, m.width)
- m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
- }
- messages = append(messages, content+"\n")
+ author = app.Info.User
case client.Assistant:
- content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
- if !cached {
- content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
- m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
+ author = message.Metadata.Assistant.ModelID
+ }
+
+ for _, p := range message.Parts {
+ part, err := p.ValueByDiscriminator()
+ if err != nil {
+ continue //TODO: handle error?
+ }
+
+ switch part.(type) {
+ // case client.MessagePartStepStart:
+ // messages = append(messages, "")
+ case client.MessagePartText:
+ text := part.(client.MessagePartText)
+ key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
+ content, cached = m.cache.Get(key)
+ if !cached {
+ content = renderText(message, text.Text, author)
+ m.cache.Set(key, content)
+ }
+ if previousBlockType != none {
+ blocks = append(blocks, "")
+ }
+ blocks = append(blocks, content)
+ if message.Role == client.User {
+ previousBlockType = userTextBlock
+ } else if message.Role == client.Assistant {
+ previousBlockType = assistantTextBlock
+ } else if message.Role == client.System {
+ previousBlockType = systemTextBlock
+ }
+ case client.MessagePartToolInvocation:
+ toolInvocationPart := part.(client.MessagePartToolInvocation)
+ toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
+ metadata := map[string]any{}
+ if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
+ metadata = message.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
+ }
+ var result *string
+ resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
+ if resultError == nil {
+ result = &resultPart.Result
+ }
+
+ if toolCall.State == "result" {
+ key := m.cache.GenerateKey(message.Id,
+ toolCall.ToolCallId,
+ m.showToolResults,
+ layout.Current.Viewport.Width,
+ )
+ content, cached = m.cache.Get(key)
+ if !cached {
+ content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
+ m.cache.Set(key, content)
+ }
+ } else {
+ // if the tool call isn't finished, never cache
+ content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
+ }
+
+ if previousBlockType != toolInvocationBlock {
+ blocks = append(blocks, "")
+ }
+ blocks = append(blocks, content)
+ previousBlockType = toolInvocationBlock
}
- messages = append(messages, content+"\n")
}
}
- m.viewport.SetContent(
- styles.BaseStyle().
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- messages...,
- ),
- ),
- )
-}
+ t := theme.CurrentTheme()
+ centered := []string{}
+ for _, block := range blocks {
+ centered = append(centered, lipgloss.PlaceHorizontal(
+ m.width,
+ lipgloss.Center,
+ block,
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ ))
+ }
-func (m *messagesCmp) View() string {
- baseStyle := styles.BaseStyle()
+ m.viewport.Height = m.height - lipgloss.Height(m.header())
+ m.viewport.SetContent(strings.Join(centered, "\n"))
+}
- if m.rendering {
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- "Loading...",
- m.working(),
- m.help(),
- ),
- )
+func (m *messagesComponent) header() string {
+ if m.app.Session.Id == "" {
+ return ""
}
- if len(m.app.Messages) == 0 {
- content := baseStyle.
- Width(m.width).
- Height(m.height - 1).
- Render(
- m.initialScreen(),
- )
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- content,
- "",
- m.help(),
- ),
- )
+ t := theme.CurrentTheme()
+ width := layout.Current.Container.Width
+ base := styles.BaseStyle().Render
+ muted := styles.Muted().Render
+ headerLines := []string{}
+ headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width))
+ if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
+ headerLines = append(headerLines, muted(m.app.Session.Share.Url))
+ } else {
+ headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}
+ header := strings.Join(headerLines, "\n")
+
+ header = styles.BaseStyle().
+ Width(width).
+ PaddingTop(1).
+ BorderBottom(true).
+ BorderForeground(t.BorderSubtle()).
+ BorderStyle(lipgloss.NormalBorder()).
+ Background(t.Background()).
+ Render(header)
+
+ return styles.ForceReplaceBackgroundWithLipgloss(header, t.Background())
+}
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- m.viewport.View(),
- m.working(),
- m.help(),
- ),
- )
+func (m *messagesComponent) View() string {
+ if len(m.app.Messages) == 0 || m.rendering {
+ return m.home()
+ }
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
+ m.viewport.View(),
+ )
}
// func hasToolsWithoutResponse(messages []message.Message) bool {
@@ -225,36 +305,7 @@ func (m *messagesCmp) View() string {
// return false
// }
-func (m *messagesCmp) working() string {
- text := ""
- if len(m.app.Messages) > 0 {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- task := ""
- if m.app.IsBusy() {
- task = "Working..."
- }
- // lastMessage := m.app.Messages[len(m.app.Messages)-1]
- // if hasToolsWithoutResponse(m.app.Messages) {
- // task = "Waiting for tool response..."
- // } else if hasUnfinishedToolCalls(m.app.Messages) {
- // task = "Building tool call..."
- // } else if !lastMessage.IsFinished() {
- // task = "Generating..."
- // }
- if task != "" {
- text += baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
- }
- }
- return text
-}
-
-func (m *messagesCmp) help() string {
+func (m *messagesComponent) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -275,11 +326,7 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline"),
)
}
return baseStyle.
@@ -287,20 +334,83 @@ func (m *messagesCmp) help() string {
Render(text)
}
-func (m *messagesCmp) initialScreen() string {
+func (m *messagesComponent) home() string {
+ t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
+ base := baseStyle.Render
+ muted := styles.Muted().Render
+
+ // mark := `
+ // ███▀▀█
+ // ███ █
+ // ▀▀▀▀▀▀ `
+ open := `
+█▀▀█ █▀▀█ █▀▀ █▀▀▄
+█░░█ █░░█ █▀▀ █░░█
+▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
+ code := `
+█▀▀ █▀▀█ █▀▀▄ █▀▀
+█░░ █░░█ █░░█ █▀▀
+▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
+
+ logo := lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ // styles.BaseStyle().Foreground(t.Primary()).Render(mark),
+ styles.Muted().Render(open),
+ styles.BaseStyle().Render(code),
+ )
+ cwd := app.Info.Path.Cwd
+ config := app.Info.Path.Config
+
+ commands := [][]string{
+ {"/help", "show help"},
+ {"/sessions", "list sessions"},
+ {"/new", "start a new session"},
+ {"/model", "switch model"},
+ {"/share", "share the current session"},
+ {"/exit", "exit the app"},
+ }
+
+ commandLines := []string{}
+ for _, command := range commands {
+ commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
+ }
+
+ logoAndVersion := lipgloss.JoinVertical(
+ lipgloss.Right,
+ logo,
+ muted(app.Info.Version),
+ )
- return baseStyle.Width(m.width).Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.app, m.width),
- "",
- lspsConfigured(m.width),
- ),
+ lines := []string{}
+ lines = append(lines, "")
+ lines = append(lines, "")
+ lines = append(lines, logoAndVersion)
+ lines = append(lines, "")
+ lines = append(lines, base("cwd ")+muted(cwd))
+ lines = append(lines, base("config ")+muted(config))
+ lines = append(lines, "")
+ lines = append(lines, commandLines...)
+ lines = append(lines, "")
+ if m.rendering {
+ lines = append(lines, styles.Muted().Render("Loading session..."))
+ } else {
+ lines = append(lines, "")
+ }
+
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
+ baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ lines...,
+ ),
+ )),
+ t.Background(),
)
}
-func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
}
@@ -311,18 +421,18 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.viewport.Width = width
- m.viewport.Height = height - 2
+ m.viewport.Height = height - lipgloss.Height(m.header())
m.attachments.Width = width + 40
m.attachments.Height = 3
m.renderView()
return nil
}
-func (m *messagesCmp) GetSize() (int, int) {
+func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height
}
-func (m *messagesCmp) Reload() tea.Cmd {
+func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
m.renderView()
@@ -330,7 +440,7 @@ func (m *messagesCmp) Reload() tea.Cmd {
}
}
-func (m *messagesCmp) BindingKeys() []key.Binding {
+func (m *messagesComponent) BindingKeys() []key.Binding {
return []key.Binding{
m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp,
@@ -339,7 +449,7 @@ func (m *messagesCmp) BindingKeys() []key.Binding {
}
}
-func NewMessagesCmp(app *app.App) tea.Model {
+func NewMessagesComponent(app *app.App) tea.Model {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
@@ -353,12 +463,13 @@ func NewMessagesCmp(app *app.App) tea.Model {
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
- return &messagesCmp{
- app: app,
- viewport: vp,
- spinner: s,
- attachments: attachments,
- showToolMessages: true,
- cache: NewMessageCache(),
+ return &messagesComponent{
+ app: app,
+ viewport: vp,
+ spinner: s,
+ attachments: attachments,
+ showToolResults: true,
+ cache: NewMessageCache(),
+ tail: true,
}
}
diff --git a/packages/tui/internal/components/chat/sidebar.go b/packages/tui/internal/components/chat/sidebar.go
deleted file mode 100644
index b2fe872ea..000000000
--- a/packages/tui/internal/components/chat/sidebar.go
+++ /dev/null
@@ -1,212 +0,0 @@
-package chat
-
-import (
- "fmt"
- "sort"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/state"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type sidebarCmp struct {
- app *app.App
- width, height int
- modFiles map[string]struct {
- additions int
- removals int
- }
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
- // TODO: History service not implemented in API yet
- // Initialize the modified files map
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
- return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg.(type) {
- case state.SessionSelectedMsg:
- // TODO: History service not implemented in API yet
- // ctx := context.Background()
- // m.loadModifiedFiles(ctx)
- // case pubsub.Event[history.File]:
- // TODO: History service not implemented in API yet
- // if msg.Payload.SessionID == m.app.CurrentSession.ID {
- // // Process the individual file change instead of reloading all files
- // ctx := context.Background()
- // m.processFileChanges(ctx, msg.Payload)
- // }
- }
- return m, nil
-}
-
-func (m *sidebarCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- shareUrl := ""
- if m.app.Session.Share != nil {
- shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
- }
-
- // qrcode := ""
- // if m.app.Session.ShareID != nil {
- // url := "https://dev.opencode.ai/share?id="
- // qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
- // }
-
- return baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.app, m.width),
- " ",
- m.sessionSection(),
- shareUrl,
- ),
- )
-}
-
-func (m *sidebarCmp) sessionSection() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- sessionKey := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render("Session")
-
- sessionValue := baseStyle.
- Foreground(t.Text()).
- Render(fmt.Sprintf(": %s", m.app.Session.Title))
-
- return sessionKey + sessionValue
-}
-
-func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- stats := ""
- if additions > 0 && removals > 0 {
- additionsStr := baseStyle.
- Foreground(t.Success()).
- PaddingLeft(1).
- Render(fmt.Sprintf("+%d", additions))
-
- removalsStr := baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1).
- Render(fmt.Sprintf("-%d", removals))
-
- content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
- stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
- } else if additions > 0 {
- additionsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Success()).
- Render(fmt.Sprintf("+%d", additions)))
- stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
- } else if removals > 0 {
- removalsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Error()).
- Render(fmt.Sprintf("-%d", removals)))
- stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
- }
-
- filePathStr := baseStyle.Render(filePath)
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- filePathStr,
- stats,
- ),
- )
-}
-
-func (m *sidebarCmp) modifiedFiles() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- modifiedFiles := baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render("Modified Files:")
-
- // If no modified files, show a placeholder message
- if m.modFiles == nil || len(m.modFiles) == 0 {
- message := "No modified files"
- remainingWidth := m.width - lipgloss.Width(message)
- if remainingWidth > 0 {
- message += strings.Repeat(" ", remainingWidth)
- }
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- baseStyle.Foreground(t.TextMuted()).Render(message),
- ),
- )
- }
-
- // Sort file paths alphabetically for consistent ordering
- var paths []string
- for path := range m.modFiles {
- paths = append(paths, path)
- }
- sort.Strings(paths)
-
- // Create views for each file in sorted order
- var fileViews []string
- for _, path := range paths {
- stats := m.modFiles[path]
- fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
- }
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- lipgloss.JoinVertical(
- lipgloss.Left,
- fileViews...,
- ),
- ),
- )
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func NewSidebarCmp(app *app.App) tea.Model {
- return &sidebarCmp{
- app: app,
- }
-}
diff --git a/packages/tui/internal/components/core/status.go b/packages/tui/internal/components/core/status.go
index 5c3e5eb3c..10a123f02 100644
--- a/packages/tui/internal/components/core/status.go
+++ b/packages/tui/internal/components/core/status.go
@@ -98,16 +98,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget() string {
+func logo() string {
t := theme.CurrentTheme()
- helpText := "ctrl+? help"
-
- return styles.Padded().
- Background(t.TextMuted()).
- Foreground(t.BackgroundDarker()).
- Bold(true).
- Render(helpText)
+ mark := styles.Bold().Foreground(t.Primary()).Render("◧ ")
+ open := styles.Muted().Render("open")
+ code := styles.BaseStyle().Bold(true).Render("code")
+ version := styles.Muted().Render(app.Info.Version)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ styles.Padded().Render(mark+open+code+" "+version),
+ t.BackgroundElement(),
+ )
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
@@ -132,16 +132,28 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
-
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusCmp) View() string {
+ if m.app.Session.Id == "" {
+ return styles.BaseStyle().
+ Width(m.width).
+ Height(2).
+ Render("")
+ }
+
t := theme.CurrentTheme()
- status := getHelpWidget()
+ logo := logo()
+ cwd := styles.Padded().
+ Foreground(t.TextMuted()).
+ Background(t.BackgroundSubtle()).
+ Render(app.Info.Path.Cwd)
+
+ sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
@@ -157,87 +169,85 @@ func (m statusCmp) View() string {
}
}
- tokensInfo := styles.Padded().
- Background(t.Text()).
- Foreground(t.BackgroundSecondary()).
+ sessionInfo = styles.Padded().
+ Background(t.BackgroundElement()).
+ Foreground(t.TextMuted()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
- status += tokensInfo
}
- diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
-
- modelName := m.model()
+ // diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
- statusWidth := max(
+ space := max(
0,
- m.width-
- lipgloss.Width(status)-
- lipgloss.Width(modelName)-
- lipgloss.Width(diagnostics),
+ m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
)
+ spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
- const minInlineWidth = 30
+ status := logo + cwd + spacer + sessionInfo
- // Display the first status message if available
- var statusMessage string
- if len(m.queue) > 0 {
- sm := m.queue[0]
- infoStyle := styles.Padded().
- Foreground(t.Background())
-
- switch sm.Level {
- case "info":
- infoStyle = infoStyle.Background(t.Info())
- case "warn":
- infoStyle = infoStyle.Background(t.Warning())
- case "error":
- infoStyle = infoStyle.Background(t.Error())
- case "debug":
- infoStyle = infoStyle.Background(t.TextMuted())
- }
+ blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+ return blank + "\n" + status
- // Truncate message if it's longer than available width
- msg := sm.Message
- availWidth := statusWidth - 10
-
- // If we have enough space, show inline
- if availWidth >= minInlineWidth {
- if len(msg) > availWidth && availWidth > 0 {
- msg = msg[:availWidth] + "..."
- }
- status += infoStyle.Width(statusWidth).Render(msg)
- } else {
- // Otherwise, prepare a full-width message to show above
- if len(msg) > m.width-10 && m.width > 10 {
- msg = msg[:m.width-10] + "..."
- }
- statusMessage = infoStyle.Width(m.width).Render(msg)
-
- // Add empty space in the status bar
- status += styles.Padded().
- Foreground(t.Text()).
- Background(t.BackgroundSecondary()).
- Width(statusWidth).
- Render("")
- }
- } else {
- status += styles.Padded().
- Foreground(t.Text()).
- Background(t.BackgroundSecondary()).
- Width(statusWidth).
- Render("")
- }
+ // Display the first status message if available
+ // var statusMessage string
+ // if len(m.queue) > 0 {
+ // sm := m.queue[0]
+ // infoStyle := styles.Padded().
+ // Foreground(t.Background())
+ //
+ // switch sm.Level {
+ // case "info":
+ // infoStyle = infoStyle.Background(t.Info())
+ // case "warn":
+ // infoStyle = infoStyle.Background(t.Warning())
+ // case "error":
+ // infoStyle = infoStyle.Background(t.Error())
+ // case "debug":
+ // infoStyle = infoStyle.Background(t.TextMuted())
+ // }
+ //
+ // // Truncate message if it's longer than available width
+ // msg := sm.Message
+ // availWidth := statusWidth - 10
+ //
+ // // If we have enough space, show inline
+ // if availWidth >= minInlineWidth {
+ // if len(msg) > availWidth && availWidth > 0 {
+ // msg = msg[:availWidth] + "..."
+ // }
+ // status += infoStyle.Width(statusWidth).Render(msg)
+ // } else {
+ // // Otherwise, prepare a full-width message to show above
+ // if len(msg) > m.width-10 && m.width > 10 {
+ // msg = msg[:m.width-10] + "..."
+ // }
+ // statusMessage = infoStyle.Width(m.width).Render(msg)
+ //
+ // // Add empty space in the status bar
+ // status += styles.Padded().
+ // Foreground(t.Text()).
+ // Background(t.BackgroundSubtle()).
+ // Width(statusWidth).
+ // Render("")
+ // }
+ // } else {
+ // status += styles.Padded().
+ // Foreground(t.Text()).
+ // Background(t.BackgroundSubtle()).
+ // Width(statusWidth).
+ // Render("")
+ // }
- status += diagnostics
- status += modelName
+ // status += diagnostics
+ // status += modelName
// If we have a separate status message, prepend it
- if statusMessage != "" {
- return statusMessage + "\n" + status
- } else {
- blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
- return blank + "\n" + status
- }
+ // if statusMessage != "" {
+ // return statusMessage + "\n" + status
+ // } else {
+ // blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+ // return blank + "\n" + status
+ // }
}
func (m *statusCmp) projectDiagnostics() string {
@@ -281,7 +291,7 @@ func (m *statusCmp) projectDiagnostics() string {
// }
return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render("No diagnostics"),
- t.BackgroundDarker(),
+ t.BackgroundElement(),
)
// if len(errorDiagnostics) == 0 &&
diff --git a/packages/tui/internal/components/dialog/custom_commands.go b/packages/tui/internal/components/dialog/custom_commands.go
index e23a90f66..c0115ad7a 100644
--- a/packages/tui/internal/components/dialog/custom_commands.go
+++ b/packages/tui/internal/components/dialog/custom_commands.go
@@ -22,7 +22,7 @@ const (
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
-func LoadCustomCommands(app *app.App) ([]Command, error) {
+func LoadCustomCommands() ([]Command, error) {
var commands []Command
homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go
index e3ee61c79..65874ebb4 100644
--- a/packages/tui/internal/components/diff/diff.go
+++ b/packages/tui/internal/components/diff/diff.go
@@ -404,10 +404,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
<entry type="TextWhitespace" style="%s"/>
</style>
`,
- getColor(t.Background()), // Background
- getColor(t.Text()), // Text
- getColor(t.Text()), // Other
- getColor(t.Error()), // Error
+ getColor(t.BackgroundSubtle()), // Background
+ getColor(t.Text()), // Text
+ getColor(t.Text()), // Other
+ getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
@@ -531,8 +531,7 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
- lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
-
+ lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
return
}
@@ -581,7 +580,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
- fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
+ fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
@@ -794,24 +793,24 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
}
// FormatDiff creates a side-by-side formatted view of a diff
-func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
- t := theme.CurrentTheme()
+func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
+ // t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
- config := NewSideBySideConfig(opts...)
+ // config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
- sb.WriteString(
- lipgloss.NewStyle().
- Background(t.DiffHunkHeader()).
- Foreground(t.Background()).
- Width(config.TotalWidth).
- Render(h.Header) + "\n",
- )
- sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
+ // sb.WriteString(
+ // lipgloss.NewStyle().
+ // Background(t.DiffHunkHeader()).
+ // Foreground(t.Background()).
+ // Width(config.TotalWidth).
+ // Render(h.Header) + "\n",
+ // )
+ sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
return sb.String(), nil
diff --git a/packages/tui/internal/components/spinner/spinner.go b/packages/tui/internal/components/spinner/spinner.go
deleted file mode 100644
index 5e1af8771..000000000
--- a/packages/tui/internal/components/spinner/spinner.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package spinner
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/charmbracelet/bubbles/spinner"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
-type Spinner struct {
- model spinner.Model
- done chan struct{}
- prog *tea.Program
- ctx context.Context
- cancel context.CancelFunc
-}
-
-// spinnerModel is the tea.Model for the spinner
-type spinnerModel struct {
- spinner spinner.Model
- message string
- quitting bool
-}
-
-func (m spinnerModel) Init() tea.Cmd {
- return m.spinner.Tick
-}
-
-func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- m.quitting = true
- return m, tea.Quit
- case spinner.TickMsg:
- var cmd tea.Cmd
- m.spinner, cmd = m.spinner.Update(msg)
- return m, cmd
- case quitMsg:
- m.quitting = true
- return m, tea.Quit
- default:
- return m, nil
- }
-}
-
-func (m spinnerModel) View() string {
- if m.quitting {
- return ""
- }
- return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
-}
-
-// quitMsg is sent when we want to quit the spinner
-type quitMsg struct{}
-
-// NewSpinner creates a new spinner with the given message
-func NewSpinner(message string) *Spinner {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = s.Style.Foreground(s.Style.GetForeground())
-
- ctx, cancel := context.WithCancel(context.Background())
-
- model := spinnerModel{
- spinner: s,
- message: message,
- }
-
- prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
- return &Spinner{
- model: s,
- done: make(chan struct{}),
- prog: prog,
- ctx: ctx,
- cancel: cancel,
- }
-}
-
-// NewThemedSpinner creates a new spinner with the given message and color
-func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = s.Style.Foreground(color)
-
- ctx, cancel := context.WithCancel(context.Background())
-
- model := spinnerModel{
- spinner: s,
- message: message,
- }
-
- prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
- return &Spinner{
- model: s,
- done: make(chan struct{}),
- prog: prog,
- ctx: ctx,
- cancel: cancel,
- }
-}
-
-// Start begins the spinner animation
-func (s *Spinner) Start() {
- go func() {
- defer close(s.done)
- go func() {
- <-s.ctx.Done()
- s.prog.Send(quitMsg{})
- }()
- _, err := s.prog.Run()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
- }
- }()
-}
-
-// Stop ends the spinner animation
-func (s *Spinner) Stop() {
- s.cancel()
- <-s.done
-} \ No newline at end of file
diff --git a/packages/tui/internal/components/spinner/spinner_test.go b/packages/tui/internal/components/spinner/spinner_test.go
deleted file mode 100644
index 065726e91..000000000
--- a/packages/tui/internal/components/spinner/spinner_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package spinner
-
-import (
- "testing"
- "time"
-)
-
-func TestSpinner(t *testing.T) {
- t.Parallel()
-
- // Create a spinner
- s := NewSpinner("Test spinner")
-
- // Start the spinner
- s.Start()
-
- // Wait a bit to let it run
- time.Sleep(100 * time.Millisecond)
-
- // Stop the spinner
- s.Stop()
-
- // If we got here without panicking, the test passes
-} \ No newline at end of file