wip: refactoring tui

This commit is contained in:
adamdottv 2025-05-29 13:12:39 -05:00
parent fce9e79d38
commit 26606ccbf7
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
3 changed files with 198 additions and 267 deletions

View file

@ -11,7 +11,6 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/tui/styles"
@ -22,64 +21,25 @@ import (
type uiMessageType int
const (
userMessageType uiMessageType = iota
assistantMessageType
toolMessageType
maxResultHeight = 10
)
type uiMessage struct {
ID string
messageType uiMessageType
content string
}
func toMarkdown(content string, focused bool, width int) string {
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
return rendered
return strings.TrimSuffix(rendered, "\n")
}
func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
func renderUserMessage(msg client.MessageInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
// Width(width - 1).
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
if isUser {
style = style.BorderForeground(t.Secondary())
}
// Apply markdown formatting and handle background color
parts := []string{
styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
}
// Remove newline at the end
parts[0] = strings.TrimSuffix(parts[0], "\n")
if len(info) > 0 {
parts = append(parts, info...)
}
rendered := style.Render(
lipgloss.JoinVertical(
lipgloss.Left,
parts...,
),
)
return rendered
}
func renderUserMessage(msg client.MessageInfo, isFocused bool, width int) uiMessage {
// var styledAttachments []string
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// var styledAttachments []string
// attachmentStyles := baseStyle.
// MarginLeft(1).
// Background(t.TextMuted()).
@ -95,16 +55,12 @@ func renderUserMessage(msg client.MessageInfo, isFocused bool, width int) uiMess
// styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
// }
info := []string{}
// Add timestamp info
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
username, _ := config.GetUsername()
info = append(info, baseStyle.
Width(width-1).
info := baseStyle.
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", username, timestamp)),
)
Render(fmt.Sprintf(" %s (%s)", username, timestamp))
content := ""
// if len(styledAttachments) > 0 {
@ -120,125 +76,175 @@ func renderUserMessage(msg client.MessageInfo, isFocused bool, width int) uiMess
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
content = renderMessage(textPart.Text, true, isFocused, width, info...)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
}
// content = renderMessage(msg.Parts, true, isFocused, width, info...)
userMsg := uiMessage{
ID: msg.Id,
messageType: userMessageType,
content: content,
}
return userMsg
return content
}
func convertToMap(input *any) (map[string]any, bool) {
if input == nil {
return nil, false // Handle nil pointer
}
value := *input // Dereference the pointer to get the interface value
m, ok := value.(map[string]any) // Type assertion
return m, ok
}
// Returns multiple uiMessages because of the tool calls
func renderAssistantMessage(
msg message.Message,
msgIndex int,
allMessages []message.Message, // we need this to get tool results and the user message
messagesService message.Service, // We need this to get the task tool messages
focusedUIMessageId string,
msg client.MessageInfo,
width int,
position int,
showToolMessages bool,
) []uiMessage {
messages := []uiMessage{}
content := strings.TrimSpace(msg.Content().String())
thinking := msg.IsThinking()
thinkingContent := msg.ReasoningContent().Thinking
finished := msg.IsFinished()
finishData := msg.FinishPart()
info := []string{}
) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Always add timestamp info
timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM")
modelName := "Assistant"
if msg.Model != "" {
modelName = models.SupportedModels[msg.Model].Name
}
info = append(info, baseStyle.
Width(width-1).
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", modelName, timestamp)),
)
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
toolStyle := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
if finished {
// Add finish info if available
switch finishData.Reason {
case message.FinishReasonCanceled:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Warning()).
Render("(canceled)"),
)
case message.FinishReasonError:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Error()).
Render("(error)"),
)
case message.FinishReasonPermissionDenied:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Info()).
Render("(permission denied)"),
)
baseStyle := styles.BaseStyle()
messages := []string{}
// content := strings.TrimSpace(msg.Content().String())
// thinking := msg.IsThinking()
// thinkingContent := msg.ReasoningContent().Thinking
// finished := msg.IsFinished()
// finishData := msg.FinishPart()
// Add timestamp info
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
modelName := msg.Metadata.Assistant.ModelID
info := 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?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
messages = append(messages, content)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolInvocation, _ := toolInvocationPart.ToolInvocation.ValueByDiscriminator()
switch toolInvocation.(type) {
case client.MessageToolInvocationToolCall:
toolCall := toolInvocation.(client.MessageToolInvocationToolCall)
toolName := toolName(toolCall.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolCall.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
" In progress...",
))
messages = append(messages, content)
case client.MessageToolInvocationToolResult:
toolInvocationResult := toolInvocation.(client.MessageToolInvocationToolResult)
toolName := toolName(toolInvocationResult.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolInvocationResult.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
result := truncateHeight(strings.TrimSpace(toolInvocationResult.Result), 10)
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
markdown := toMarkdown(result, width)
content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
markdown,
))
messages = append(messages, content)
}
}
}
if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
if content == "" {
content = "*Finished without output*"
}
// if finished {
// // Add finish info if available
// switch finishData.Reason {
// case message.FinishReasonCanceled:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Warning()).
// Render("(canceled)"),
// )
// case message.FinishReasonError:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Error()).
// Render("(error)"),
// )
// case message.FinishReasonPermissionDenied:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Info()).
// Render("(permission denied)"),
// )
// }
// }
content = renderMessage(content, false, true, width, info...)
messages = append(messages, uiMessage{
ID: msg.ID,
messageType: assistantMessageType,
// position: position,
// height: lipgloss.Height(content),
content: content,
})
// position += messages[0].height
position++ // for the space
} else if thinking && thinkingContent != "" {
// Render the thinking content with timestamp
content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width, info...)
messages = append(messages, uiMessage{
ID: msg.ID,
messageType: assistantMessageType,
// position: position,
// height: lipgloss.Height(content),
content: content,
})
position += lipgloss.Height(content)
position++ // for the space
}
// if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
// if content == "" {
// content = "*Finished without output*"
// }
//
// content = renderMessage(content, false, width, info...)
// messages = append(messages, content)
// // position += messages[0].height
// position++ // for the space
// } else if thinking && thinkingContent != "" {
// // Render the thinking content with timestamp
// content = renderMessage(thinkingContent, false, width, info...)
// messages = append(messages, content)
// position += lipgloss.Height(content)
// position++ // for the space
// }
// Only render tool messages if they should be shown
if showToolMessages {
for i, toolCall := range msg.ToolCalls() {
toolCallContent := renderToolMessage(
toolCall,
allMessages,
messagesService,
focusedUIMessageId,
false,
width,
i+1,
)
messages = append(messages, toolCallContent)
// position += toolCallContent.height
position++ // for the space
}
// for i, toolCall := range msg.ToolCalls() {
// toolCallContent := renderToolMessage(
// toolCall,
// allMessages,
// messagesService,
// focusedUIMessageId,
// false,
// width,
// i+1,
// )
// messages = append(messages, toolCallContent)
// }
}
return messages
return strings.Join(messages, "\n\n")
}
func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
@ -497,7 +503,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
case tools.BashToolName:
resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
toMarkdown(resultContent, width),
t.Background(),
)
case tools.EditToolName:
@ -517,7 +523,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
}
resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
toMarkdown(resultContent, width),
t.Background(),
)
case tools.GlobToolName:
@ -537,7 +543,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
}
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
toMarkdown(resultContent, width),
t.Background(),
)
case tools.WriteToolName:
@ -553,7 +559,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
}
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
toMarkdown(resultContent, width),
t.Background(),
)
case tools.BatchToolName:
@ -591,7 +597,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
default:
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
toMarkdown(resultContent, width),
t.Background(),
)
}
@ -605,7 +611,7 @@ func renderToolMessage(
nested bool,
width int,
position int,
) uiMessage {
) string {
if nested {
width = width - 3
}
@ -634,13 +640,7 @@ func renderToolMessage(
Render(fmt.Sprintf("%s", toolAction))
content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
toolMsg := uiMessage{
messageType: toolMessageType,
// position: position,
// height: lipgloss.Height(content),
content: content,
}
return toolMsg
return content
}
params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
@ -702,11 +702,5 @@ func renderToolMessage(
parts...,
)
}
toolMsg := uiMessage{
messageType: toolMessageType,
// position: position,
// height: lipgloss.Height(content),
content: content,
}
return toolMsg
return content
}

View file

@ -1,8 +1,6 @@
package chat
import (
"fmt"
"math"
"time"
"github.com/charmbracelet/bubbles/key"
@ -20,18 +18,11 @@ import (
"github.com/sst/opencode/pkg/client"
)
type cacheItem struct {
width int
content []uiMessage
}
type messagesCmp struct {
app *app.App
width, height int
viewport viewport.Model
uiMessages []uiMessage
currentMsgID string
cachedContent map[string]cacheItem
spinner spinner.Model
rendering bool
attachments viewport.Model
@ -71,17 +62,17 @@ func (m *messagesCmp) Init() tea.Cmd {
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.renderView()
// m.renderView()
var cmds []tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.rerender()
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages
// Clear the cache to force re-rendering of all messages
m.cachedContent = make(map[string]cacheItem)
// m.cachedContent = make(map[string]cacheItem)
m.renderView()
return m, nil
case state.SessionSelectedMsg:
@ -171,93 +162,43 @@ func (m *messagesCmp) IsAgentWorking() bool {
return m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID)
}
func formatTimeDifference(unixTime1, unixTime2 int64) string {
diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
if diffSeconds < 60 {
return fmt.Sprintf("%.1fs", diffSeconds)
}
minutes := int(diffSeconds / 60)
seconds := int(diffSeconds) % 60
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
func (m *messagesCmp) renderView() {
m.uiMessages = make([]uiMessage, 0)
baseStyle := styles.BaseStyle()
if m.width == 0 {
return
}
t := theme.CurrentTheme()
messages := make([]string, 0)
for _, msg := range m.app.Messages {
switch msg.Role {
case client.User:
if cache, ok := m.cachedContent[msg.Id]; ok && cache.width == m.width {
m.uiMessages = append(m.uiMessages, cache.content...)
continue
}
userMsg := renderUserMessage(
content := renderUserMessage(
msg,
msg.Id == m.currentMsgID,
m.width,
)
m.uiMessages = append(m.uiMessages, userMsg)
m.cachedContent[msg.Id] = cacheItem{
width: m.width,
content: []uiMessage{userMsg},
}
// pos += userMsg.height + 1 // + 1 for spacing
messages = append(messages, content+"\n")
case client.Assistant:
if cache, ok := m.cachedContent[msg.Id]; ok && cache.width == m.width {
m.uiMessages = append(m.uiMessages, cache.content...)
continue
}
// assistantMessages := renderAssistantMessage(
// msg,
// inx,
// m.app.Messages,
// m.app.MessagesOLD,
// m.currentMsgID,
// m.width,
// pos,
// m.showToolMessages,
// )
// for _, msg := range assistantMessages {
// m.uiMessages = append(m.uiMessages, msg)
// // pos += msg.height + 1 // + 1 for spacing
// }
// m.cachedContent[msg.Id] = cacheItem{
// width: m.width,
// content: assistantMessages,
// }
content := renderAssistantMessage(
msg,
m.width,
m.showToolMessages,
)
messages = append(messages, content+"\n")
}
}
messages := make([]string, 0)
for _, v := range m.uiMessages {
messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
baseStyle.
m.viewport.SetContent(
styles.ForceReplaceBackgroundWithLipgloss(
styles.BaseStyle().
Width(m.width).
Render(
"",
lipgloss.JoinVertical(
lipgloss.Top,
messages...,
),
),
)
}
// temp, _ := json.MarshalIndent(m.app.State, "", " ")
m.viewport.SetContent(
baseStyle.
Width(m.width).
Render(
// string(temp),
lipgloss.JoinVertical(
lipgloss.Top,
messages...,
),
),
t.Background(),
),
)
}
@ -416,13 +357,6 @@ func (m *messagesCmp) initialScreen() string {
)
}
func (m *messagesCmp) rerender() {
for _, msg := range m.app.Messages {
delete(m.cachedContent, msg.Id)
}
m.renderView()
}
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
@ -433,7 +367,7 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
m.viewport.Height = height - 2
m.attachments.Width = width + 40
m.attachments.Height = 3
m.rerender()
m.renderView()
return nil
}
@ -453,7 +387,7 @@ func (m *messagesCmp) Reload(session *session.Session) tea.Cmd {
if len(m.app.Messages) > 0 {
m.currentMsgID = m.app.Messages[len(m.app.Messages)-1].Id
}
delete(m.cachedContent, m.currentMsgID)
// delete(m.cachedContent, m.currentMsgID)
m.rendering = true
return func() tea.Msg {
m.renderView()
@ -476,18 +410,19 @@ func NewMessagesCmp(app *app.App) tea.Model {
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New(0, 0)
attachmets := viewport.New(0, 0)
attachments := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{
app: app,
cachedContent: make(map[string]cacheItem),
viewport: vp,
spinner: s,
attachments: attachmets,
attachments: attachments,
showToolMessages: true,
}
}

View file

@ -285,7 +285,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Session = &sessionInfo
}
return a, nil
return a.updateAllPages(state.StateUpdatedMsg{State: a.app.State})
}
if parts[0] == "session" && parts[1] == "message" {
@ -303,7 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.Id == messageId {
a.app.Messages[i] = message
slog.Debug("Updated message", "message", message)
return a, nil
return a.updateAllPages(state.StateUpdatedMsg{State: a.app.State})
}
}
@ -316,7 +317,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// a.app.CurrentSession.Cost += message.Cost
// a.app.CurrentSession.UpdatedAt = message.CreatedAt
}
return a, nil
return a.updateAllPages(state.StateUpdatedMsg{State: a.app.State})
}
// log key and content