diff options
| author | adamdottv <[email protected]> | 2025-06-05 15:44:20 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-06-11 11:43:28 -0500 |
| commit | 95d5e1f2318e0c62f19196122fc2a448f1114cfd (patch) | |
| tree | 75369872d32e10896e9263ddbf32cf36e7e418ac /packages/tui/internal/components/chat | |
| parent | 979bad3e64e3fff43d41094a79c73deb31e82ec8 (diff) | |
| download | opencode-95d5e1f2318e0c62f19196122fc2a448f1114cfd.tar.gz opencode-95d5e1f2318e0c62f19196122fc2a448f1114cfd.zip | |
wip: refactoring tui
Diffstat (limited to 'packages/tui/internal/components/chat')
| -rw-r--r-- | packages/tui/internal/components/chat/cache.go | 36 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/chat.go | 109 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 254 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 490 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 393 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/sidebar.go | 212 |
6 files changed, 712 insertions, 782 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, - } -} |
