diff options
| author | adamdottv <[email protected]> | 2025-07-02 16:08:06 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-07-02 16:08:11 -0500 |
| commit | c82a060eca41b990b4dd89cecffb874b2133af6f (patch) | |
| tree | c3bb84aee2e621da9feb18f866c88fc51da0e7c1 /packages/tui/internal/components/chat | |
| parent | 63e783ef795d91c745733b945247e917f1683d31 (diff) | |
| download | opencode-c82a060eca41b990b4dd89cecffb874b2133af6f.tar.gz opencode-c82a060eca41b990b4dd89cecffb874b2133af6f.zip | |
feat(tui): file viewer, select messages
Diffstat (limited to 'packages/tui/internal/components/chat')
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 95 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 279 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 216 |
3 files changed, 265 insertions, 325 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index b4abd0f89..669ef47d0 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -13,7 +13,6 @@ import ( "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/image" - "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -21,10 +20,8 @@ import ( type EditorComponent interface { tea.Model - // tea.ViewModel - SetSize(width, height int) tea.Cmd - View(width int, align lipgloss.Position) string - Content(width int, align lipgloss.Position) string + View(width int) string + Content(width int) string Lines() int Value() string Focused() bool @@ -34,19 +31,13 @@ type EditorComponent interface { Clear() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd) - Previous() (tea.Model, tea.Cmd) - Next() (tea.Model, tea.Cmd) SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { app *app.App - width, height int textarea textarea.Model attachments []app.Attachment - history []string - historyIndex int - currentMessage string spinner spinner.Model interruptKeyInDebounce bool } @@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *editorComponent) Content(width int, align lipgloss.Position) string { +func (m *editorComponent) Content(width int) string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render @@ -115,6 +106,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { Bold(true) prompt := promptStyle.Render(">") + m.textarea.SetWidth(width - 6) textarea := lipgloss.JoinHorizontal( lipgloss.Top, prompt, @@ -147,7 +139,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) } - space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) + space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") info := hint + spacer + model @@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { return content } -func (m *editorComponent) View(width int, align lipgloss.Position) string { +func (m *editorComponent) View(width int) string { if m.Lines() > 1 { - t := theme.CurrentTheme() return lipgloss.Place( width, - m.height, - align, + 5, + lipgloss.Center, lipgloss.Center, "", - styles.WhitespaceStyle(t.Background()), + styles.WhitespaceStyle(theme.CurrentTheme().Background()), ) } - return m.Content(width, align) + return m.Content(width) } func (m *editorComponent) Focused() bool { @@ -184,16 +175,6 @@ func (m *editorComponent) Blur() { m.textarea.Blur() } -func (m *editorComponent) GetSize() (width, height int) { - return m.width, m.height -} - -func (m *editorComponent) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - return nil -} - func (m *editorComponent) Lines() int { return m.textarea.LineCount() } @@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) 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 cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) @@ -261,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) Previous() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - - // Only navigate history if we're at the first line - if currentLine == 0 && len(m.history) > 0 { - // Save current message if we're just starting to navigate - if m.historyIndex == len(m.history) { - m.currentMessage = m.textarea.Value() - } - - // Go to previous message in history - if m.historyIndex > 0 { - m.historyIndex-- - m.textarea.SetValue(m.history[m.historyIndex]) - } - return m, nil - } - return m, nil -} - -func (m *editorComponent) Next() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - value := m.textarea.Value() - lines := strings.Split(value, "\n") - totalLines := len(lines) - - // Only navigate history if we're at the last line - if currentLine == totalLines-1 { - if m.historyIndex < len(m.history)-1 { - // Go to next message in history - m.historyIndex++ - m.textarea.SetValue(m.history[m.historyIndex]) - } else if m.historyIndex == len(m.history)-1 { - // Return to the current message being composed - m.historyIndex = len(m.history) - m.textarea.SetValue(m.currentMessage) - } - return m, nil - } - return m, nil -} - func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { m.interruptKeyInDebounce = inDebounce } @@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model { ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = -1 - ta.SetWidth(layout.Current.Container.Width - 6) if existing != nil { ta.SetValue(existing.Value()) @@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent { return &editorComponent{ app: app, textarea: ta, - history: []string{}, - historyIndex: 0, - currentMessage: "", spinner: s, interruptKeyInDebounce: false, } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 8e4cbc1af..4ef738569 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -3,65 +3,46 @@ package chat import ( "encoding/json" "fmt" - "path/filepath" "slices" "strings" "time" - "unicode" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" "github.com/tidwall/gjson" "golang.org/x/text/cases" "golang.org/x/text/language" ) -func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) - content = strings.ReplaceAll(content, app.RootPath+"/", "") - rendered, _ := r.Render(content) - lines := strings.Split(rendered, "\n") - - if len(lines) > 0 { - firstLine := lines[0] - cleaned := ansi.Strip(firstLine) - nospace := strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[1:] - } - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - cleaned = ansi.Strip(lastLine) - nospace = strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[:len(lines)-1] - } - } - } - content = strings.Join(lines, "\n") - return strings.TrimSuffix(content, "\n") -} - type blockRenderer struct { - border bool - borderColor *compat.AdaptiveColor - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int + textColor compat.AdaptiveColor + border bool + borderColor *compat.AdaptiveColor + borderColorRight bool + paddingTop int + paddingBottom int + paddingLeft int + paddingRight int + marginTop int + marginBottom int } type renderingOption func(*blockRenderer) +func WithTextColor(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.textColor = color + } +} + func WithNoBorder() renderingOption { return func(c *blockRenderer) { c.border = false @@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption { } } +func WithBorderColorRight(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.borderColorRight = true + c.borderColor = &color + } +} + func WithMarginTop(padding int) renderingOption { return func(c *blockRenderer) { c.marginTop = padding @@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption { } func renderContentBlock( + app *app.App, content string, + highlight bool, width int, - align lipgloss.Position, options ...renderingOption, ) string { t := theme.CurrentTheme() renderer := &blockRenderer{ + textColor: t.TextMuted(), border: true, paddingTop: 1, paddingBottom: 1, @@ -143,7 +133,7 @@ func renderContentBlock( } style := styles.NewStyle(). - Foreground(t.TextMuted()). + Foreground(renderer.textColor). Background(t.BackgroundPanel()). Width(width). PaddingTop(renderer.paddingTop). @@ -161,21 +151,32 @@ func renderContentBlock( BorderLeftBackground(t.Background()). BorderRightForeground(t.BackgroundPanel()). BorderRightBackground(t.Background()) + + if renderer.borderColorRight { + style = style. + BorderLeftBackground(t.Background()). + BorderLeftForeground(t.BackgroundPanel()). + BorderRightForeground(borderColor). + BorderRightBackground(t.Background()) + } + + if highlight { + style = style. + BorderLeftBackground(t.Primary()). + BorderLeftForeground(t.Primary()). + BorderRightForeground(t.Primary()). + BorderRightBackground(t.Primary()) + } + } + + if highlight { + style = style. + Foreground(t.Text()). + Bold(true). + Background(t.BackgroundElement()) } content = style.Render(content) - content = lipgloss.PlaceHorizontal( - width, - lipgloss.Left, - content, - styles.WhitespaceStyle(t.Background()), - ) - content = lipgloss.PlaceHorizontal( - layout.Current.Viewport.Width, - align, - content, - styles.WhitespaceStyle(t.Background()), - ) if renderer.marginTop > 0 { for range renderer.marginTop { content = "\n" + content @@ -186,16 +187,44 @@ func renderContentBlock( content = content + "\n" } } + + if highlight { + copy := app.Key(commands.MessagesCopyCommand) + // revert := app.Key(commands.MessagesRevertCommand) + + background := t.Background() + header := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: copy, + }, + // layout.FlexItem{ + // View: revert, + // }, + ) + header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header) + + content = "\n\n\n" + header + "\n\n" + content + "\n\n" + } + return content } func renderText( + app *app.App, message opencode.Message, text string, author string, showToolDetails bool, + highlight bool, width int, - align lipgloss.Position, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -206,17 +235,20 @@ func renderText( timestamp = timestamp[12:] } info := fmt.Sprintf("%s (%s)", author, timestamp) + info = styles.NewStyle().Foreground(t.TextMuted()).Render(info) - messageStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.Text()) + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } + messageStyle := styles.NewStyle().Background(backgroundColor) if message.Role == opencode.MessageRoleUser { messageStyle = messageStyle.Width(width - 6) } content := messageStyle.Render(text) if message.Role == opencode.MessageRoleAssistant { - content = toMarkdown(text, width, t.BackgroundPanel()) + content = util.ToMarkdown(text, width, backgroundColor) } if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { @@ -242,16 +274,19 @@ func renderText( switch message.Role { case opencode.MessageRoleUser: return renderContentBlock( + app, content, + highlight, width, - align, - WithBorderColor(t.Secondary()), + WithTextColor(t.Text()), + WithBorderColorRight(t.Secondary()), ) case opencode.MessageRoleAssistant: return renderContentBlock( + app, content, + highlight, width, - align, WithBorderColor(t.Accent()), ) } @@ -259,10 +294,11 @@ func renderText( } func renderToolDetails( + app *app.App, toolCall opencode.ToolInvocationPart, messageMetadata opencode.MessageMetadata, + highlight bool, width int, - align lipgloss.Position, ) string { ignoredTools := []string{"todoread"} if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { @@ -282,7 +318,7 @@ func renderToolDetails( if toolCall.ToolInvocation.State == "partial-call" { title := renderToolTitle(toolCall, messageMetadata, width) - return renderContentBlock(title, width, align) + return renderContentBlock(app, title, highlight, width) } toolArgsMap := make(map[string]any) @@ -301,6 +337,10 @@ func renderToolDetails( body := "" finished := result != nil && *result != "" t := theme.CurrentTheme() + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } switch toolCall.ToolInvocation.ToolName { case "read": @@ -308,7 +348,7 @@ func renderToolDetails( if preview != nil && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) - body = renderFile(filename, body, width, WithTruncate(6)) + body = util.RenderFile(filename, body, width, util.WithTruncate(6)) } case "edit": if filename, ok := toolArgsMap["filePath"].(string); ok { @@ -321,38 +361,28 @@ func renderToolDetails( patch, diff.WithWidth(width-2), ) - formattedDiff = strings.TrimSpace(formattedDiff) - formattedDiff = styles.NewStyle(). - BorderStyle(lipgloss.ThickBorder()). - BorderBackground(t.Background()). - BorderForeground(t.BackgroundPanel()). - BorderLeft(true). - BorderRight(true). - Render(formattedDiff) - body = strings.TrimSpace(formattedDiff) - body = renderContentBlock( - body, - width, - align, - WithNoBorder(), - WithPadding(0), - ) + style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4) + if highlight { + style = style.Foreground(t.Text()).Bold(true) + } if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, width, align) + diagnostics = style.Render(diagnostics) + body += "\n" + diagnostics } title := renderToolTitle(toolCall, messageMetadata, width) - title = renderContentBlock(title, width, align) + title = style.Render(title) content := title + "\n" + body + content = renderContentBlock(app, content, highlight, width, WithPadding(0)) return content } } case "write": if filename, ok := toolArgsMap["filePath"].(string); ok { if content, ok := toolArgsMap["content"].(string); ok { - body = renderFile(filename, content, width) + body = util.RenderFile(filename, content, width) if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { body += "\n\n" + diagnostics } @@ -363,14 +393,14 @@ func renderToolDetails( if stdout != nil { command := toolArgsMap["command"].(string) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "webfetch": if format, ok := toolArgsMap["format"].(string); ok && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) if format == "html" || format == "markdown" { - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } } case "todowrite": @@ -389,7 +419,7 @@ func renderToolDetails( body += fmt.Sprintf("- [ ] %s\n", content) } } - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "task": summary := metadata.JSON.ExtraFields["summary"] @@ -424,7 +454,7 @@ func renderToolDetails( result = &empty } body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } error := "" @@ -437,18 +467,18 @@ func renderToolDetails( if error != "" { body = styles.NewStyle(). Foreground(t.Error()). - Background(t.BackgroundPanel()). + Background(backgroundColor). Render(error) } if body == "" && error == "" && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } title := renderToolTitle(toolCall, messageMetadata, width) content := title + "\n\n" + body - return renderContentBlock(content, width, align) + return renderContentBlock(app, content, highlight, width) } func renderToolName(name string) string { @@ -505,7 +535,7 @@ func renderToolTitle( title = fmt.Sprintf("%s %s", title, toolArgs) case "edit", "write": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, relative(filename)) + title = fmt.Sprintf("%s %s", title, util.Relative(filename)) } case "bash", "task": if description, ok := toolArgsMap["description"].(string); ok { @@ -551,50 +581,6 @@ func renderToolAction(name string) string { return "Working..." } -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, - width int, - options ...fileRenderingOption) string { - t := theme.CurrentTheme() - renderer := &fileRenderer{ - filename: filename, - content: content, - } - for _, option := range options { - option(renderer) - } - - 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") - - if renderer.height > 0 { - content = truncateHeight(content, renderer.height) - } - content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) - content = toMarkdown(content, width, t.BackgroundPanel()) - return content -} - func renderArgs(args *map[string]any, titleKey string) string { if args == nil || len(*args) == 0 { return "" @@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string { continue } if key == "filePath" || key == "path" { - value = relative(value.(string)) + value = util.Relative(value.(string)) } if key == titleKey { title = fmt.Sprintf("%s", value) @@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string { return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) } -func truncateHeight(content string, height int) string { - lines := strings.Split(content, "\n") - if len(lines) > height { - return strings.Join(lines[:height], "\n") - } - return content -} - -func relative(path string) string { - path = strings.TrimPrefix(path, app.CwdPath+"/") - return strings.TrimPrefix(path, app.RootPath+"/") -} - -func extension(path string) string { - ext := filepath.Ext(path) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - return ext -} - // Diagnostic represents an LSP diagnostic type Diagnostic struct { Range struct { diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index fbe05d70d..a0105ec42 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -9,7 +9,6 @@ import ( "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" - "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -17,73 +16,99 @@ import ( type MessagesComponent interface { tea.Model - tea.ViewModel - // View(width int) string - SetSize(width, height int) tea.Cmd + View(width, height int) string + SetWidth(width int) tea.Cmd PageUp() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd) - // Previous() (tea.Model, tea.Cmd) - // Next() (tea.Model, tea.Cmd) + Previous() (tea.Model, tea.Cmd) + Next() (tea.Model, tea.Cmd) ToolDetailsVisible() bool + Selected() string } type messagesComponent struct { - width, height int + width int app *app.App viewport viewport.Model - attachments viewport.Model cache *MessageCache rendering bool showToolDetails bool tail bool + partCount int + lineCount int + selectedPart int + selectedText string } type renderFinishedMsg struct{} +type selectedMessagePartChangedMsg struct { + part int +} + type ToggleToolDetailsMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) } +func (m *messagesComponent) Selected() string { + return m.selectedText +} + func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg.(type) { + switch msg := msg.(type) { case app.SendMsg: m.viewport.GotoBottom() m.tail = true + m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView() + m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } return m, nil case dialog.ThemeSelectedMsg: m.cache.Clear() + m.rendering = true return m, m.Reload() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails + m.rendering = true return m, m.Reload() - case app.SessionSelectedMsg: + case app.SessionLoadedMsg: m.cache.Clear() m.tail = true + m.rendering = true return m, m.Reload() case app.SessionClearedMsg: m.cache.Clear() - cmd := m.Reload() - return m, cmd + m.rendering = true + return m, m.Reload() case renderFinishedMsg: m.rendering = false if m.tail { m.viewport.GotoBottom() } - case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated: - m.renderView() - if m.tail { - m.viewport.GotoBottom() + case selectedMessagePartChangedMsg: + return m, m.Reload() + case opencode.EventListResponseEventSessionUpdated: + if msg.Properties.Info.ID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } + } + case opencode.EventListResponseEventMessageUpdated: + if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } } } @@ -95,45 +120,46 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *messagesComponent) renderView() { - if m.width == 0 { - return - } - +func (m *messagesComponent) renderView(width int) { measure := util.Measure("messages.renderView") defer measure("messageCount", len(m.app.Messages)) t := theme.CurrentTheme() + blocks := make([]string, 0) + m.partCount = 0 + m.lineCount = 0 - align := lipgloss.Center - width := layout.Current.Container.Width - - sb := strings.Builder{} - util.MapReducePar(m.app.Messages, &sb, func(message opencode.Message) func(*strings.Builder) *strings.Builder { + for _, message := range m.app.Messages { var content string var cached bool - blocks := make([]string, 0) switch message.Role { case opencode.MessageRoleUser: for _, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width) + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, part.Text, m.app.Info.User, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = part.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -162,33 +188,41 @@ func (m *messagesComponent) renderView() { } if finished { - key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails) + key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) m.cache.Set(key, content) } } else { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = p.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } case opencode.ToolInvocationPart: if !m.showToolDetails { @@ -199,29 +233,38 @@ func (m *messagesComponent) renderView() { key := m.cache.GenerateKey(message.ID, part.ToolInvocation.ToolCallID, m.showToolDetails, - layout.Current.Viewport.Width, + width, + m.partCount == m.selectedPart, ) content, cached = m.cache.Get(key) if !cached { content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = "" + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -240,41 +283,33 @@ func (m *messagesComponent) renderView() { if error != "" { error = renderContentBlock( + m.app, error, + false, width, - align, WithBorderColor(t.Error()), ) blocks = append(blocks, error) + m.lineCount += lipgloss.Height(error) + 1 } + } - str := strings.Join(blocks, "\n\n") - return func(sbdr *strings.Builder) *strings.Builder { - if sbdr.Len() > 0 && str != "" { - sbdr.WriteString("\n\n") - } - sbdr.WriteString(str) - return sbdr - } - }) - - content := sb.String() - - m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1) - m.viewport.SetContent("\n" + content) + m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n")) + if m.selectedPart == m.partCount-1 { + m.viewport.GotoBottom() + } } -func (m *messagesComponent) header() string { +func (m *messagesComponent) header(width int) string { if m.app.Session.ID == "" { return "" } t := theme.CurrentTheme() - width := layout.Current.Container.Width base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render headerLines := []string{} - headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) + headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background())) if m.app.Session.Share.URL != "" { headerLines = append(headerLines, muted(m.app.Session.Share.URL)) } else { @@ -297,31 +332,29 @@ func (m *messagesComponent) header() string { return "\n" + header + "\n" } -func (m *messagesComponent) View() string { +func (m *messagesComponent) View(width, height int) string { t := theme.CurrentTheme() if m.rendering { return lipgloss.Place( - m.width, - m.height+1, + width, + height, lipgloss.Center, lipgloss.Center, styles.NewStyle().Background(t.Background()).Render("Loading session..."), styles.WhitespaceStyle(t.Background()), ) } - header := lipgloss.PlaceHorizontal( - m.width, - lipgloss.Center, - m.header(), - styles.WhitespaceStyle(t.Background()), - ) + header := m.header(width) + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - lipgloss.Height(header)) + return styles.NewStyle(). Background(t.Background()). Render(header + "\n" + m.viewport.View()) } -func (m *messagesComponent) SetSize(width, height int) tea.Cmd { - if m.width == width && m.height == height { +func (m *messagesComponent) SetWidth(width int) tea.Cmd { + if m.width == width { return nil } // Clear cache on resize since width affects rendering @@ -329,23 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd { m.cache.Clear() } m.width = width - m.height = height m.viewport.SetWidth(width) - m.viewport.SetHeight(height - lipgloss.Height(m.header())) - m.attachments.SetWidth(width + 40) - m.attachments.SetHeight(3) - m.renderView() + m.renderView(width) return nil } -func (m *messagesComponent) GetSize() (int, int) { - return m.width, m.height -} - func (m *messagesComponent) Reload() tea.Cmd { - m.rendering = true return func() tea.Msg { - m.renderView() + m.renderView(m.width) return renderFinishedMsg{} } } @@ -370,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { return m, nil } +func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) { + m.tail = false + if m.selectedPart < 0 { + m.selectedPart = m.partCount + } + m.selectedPart-- + if m.selectedPart < 0 { + m.selectedPart = 0 + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) Next() (tea.Model, tea.Cmd) { + m.tail = false + m.selectedPart++ + if m.selectedPart >= m.partCount { + m.selectedPart = m.partCount + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + func (m *messagesComponent) First() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() + m.selectedPart = 0 m.tail = false - return m, nil + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() + m.selectedPart = m.partCount - 1 m.tail = true - return m, nil + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) ToolDetailsVisible() bool { @@ -388,15 +441,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool { func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() - attachments := viewport.New() vp.KeyMap = viewport.KeyMap{} return &messagesComponent{ app: app, viewport: vp, - attachments: attachments, showToolDetails: true, cache: NewMessageCache(), tail: true, + selectedPart: -1, } } |
