summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/chat
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tui/internal/components/chat')
-rw-r--r--packages/tui/internal/components/chat/editor.go95
-rw-r--r--packages/tui/internal/components/chat/message.go279
-rw-r--r--packages/tui/internal/components/chat/messages.go216
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,
}
}