From 95d5e1f2318e0c62f19196122fc2a448f1114cfd Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 5 Jun 2025 15:44:20 -0500 Subject: wip: refactoring tui --- packages/tui/internal/components/chat/cache.go | 36 +- packages/tui/internal/components/chat/chat.go | 109 ----- packages/tui/internal/components/chat/editor.go | 254 ++++++----- packages/tui/internal/components/chat/message.go | 490 +++++++++++++-------- packages/tui/internal/components/chat/messages.go | 393 +++++++++++------ packages/tui/internal/components/chat/sidebar.go | 212 --------- packages/tui/internal/components/core/status.go | 172 ++++---- .../internal/components/dialog/custom_commands.go | 2 +- packages/tui/internal/components/diff/diff.go | 35 +- .../tui/internal/components/spinner/spinner.go | 127 ------ .../internal/components/spinner/spinner_test.go | 24 - 11 files changed, 821 insertions(+), 1033 deletions(-) delete mode 100644 packages/tui/internal/components/chat/sidebar.go delete mode 100644 packages/tui/internal/components/spinner/spinner.go delete mode 100644 packages/tui/internal/components/spinner/spinner_test.go (limited to 'packages/tui/internal/components') 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 `, - 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 -- cgit v1.2.3