diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 6dae5418d4ffeef5d4f29703b7bc45a580d9d958..7512bfc8ad775eb58accd9890c8a66d19adec3f8 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -211,7 +211,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.send() } } - } m.textarea, cmd = m.textarea.Update(msg) return m, cmd @@ -233,7 +232,8 @@ func (m *editorCmp) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.attachmentsContent(), lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), - m.textarea.View()), + m.textarea.View(), + ), ) } diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 7bc3c4e81281c7ce1e41153315db89bdd08d79d3..95f0e4961519695168ce35e1db68e5958cb482e3 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -1,44 +1,49 @@ package chat -import ( - "context" - "fmt" - "math" - - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/spinner" - "github.com/charmbracelet/bubbles/v2/viewport" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type cacheItem struct { - width int - content []uiMessage -} -type messagesCmp struct { - app *app.App - width, height int - viewport viewport.Model - session session.Session - messages []message.Message - uiMessages []uiMessage - currentMsgID string - cachedContent map[string]cacheItem - spinner spinner.Model - rendering bool - attachments viewport.Model -} -type renderFinishedMsg struct{} - +import "github.com/charmbracelet/bubbles/v2/key" + +// import ( +// +// "context" +// "fmt" +// "math" +// +// "github.com/charmbracelet/bubbles/v2/key" +// "github.com/charmbracelet/bubbles/v2/spinner" +// "github.com/charmbracelet/bubbles/v2/viewport" +// tea "github.com/charmbracelet/bubbletea/v2" +// "github.com/charmbracelet/lipgloss/v2" +// "github.com/opencode-ai/opencode/internal/app" +// "github.com/opencode-ai/opencode/internal/message" +// "github.com/opencode-ai/opencode/internal/pubsub" +// "github.com/opencode-ai/opencode/internal/session" +// "github.com/opencode-ai/opencode/internal/tui/components/dialog" +// "github.com/opencode-ai/opencode/internal/tui/styles" +// "github.com/opencode-ai/opencode/internal/tui/theme" +// "github.com/opencode-ai/opencode/internal/tui/util" +// +// ) +// +// type cacheItem struct { +// width int +// content []uiMessage +// } +// +// type messagesCmp struct { +// app *app.App +// width, height int +// viewport viewport.Model +// session session.Session +// messages []message.Message +// uiMessages []uiMessage +// currentMsgID string +// cachedContent map[string]cacheItem +// spinner spinner.Model +// rendering bool +// attachments viewport.Model +// } +// +// type renderFinishedMsg struct{} type MessageKeys struct { PageDown key.Binding PageUp key.Binding @@ -65,410 +70,411 @@ var messageKeys = MessageKeys{ ), } -func (m *messagesCmp) Init() tea.Cmd { - return tea.Batch(m.viewport.Init(), m.spinner.Tick) -} - -func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case dialog.ThemeChangedMsg: - m.rerender() - return m, nil - case SessionSelectedMsg: - if msg.ID != m.session.ID { - cmd := m.SetSession(msg) - return m, cmd - } - return m, nil - case SessionClearedMsg: - m.session = session.Session{} - m.messages = make([]message.Message, 0) - m.currentMsgID = "" - m.rendering = false - return m, nil - - case tea.KeyMsg: - 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 - cmds = append(cmds, cmd) - } - - case renderFinishedMsg: - m.rendering = false - m.viewport.GotoBottom() - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID { - m.session = msg.Payload - if m.session.SummaryMessageID == m.currentMsgID { - delete(m.cachedContent, m.currentMsgID) - m.renderView() - } - } - case pubsub.Event[message.Message]: - needsRerender := false - if msg.Type == pubsub.CreatedEvent { - if msg.Payload.SessionID == m.session.ID { - - messageExists := false - for _, v := range m.messages { - if v.ID == msg.Payload.ID { - messageExists = true - break - } - } - - if !messageExists { - if len(m.messages) > 0 { - lastMsgID := m.messages[len(m.messages)-1].ID - delete(m.cachedContent, lastMsgID) - } - - m.messages = append(m.messages, msg.Payload) - delete(m.cachedContent, m.currentMsgID) - m.currentMsgID = msg.Payload.ID - needsRerender = true - } - } - // There are tool calls from the child task - for _, v := range m.messages { - for _, c := range v.ToolCalls() { - if c.ID == msg.Payload.SessionID { - delete(m.cachedContent, v.ID) - needsRerender = true - } - } - } - } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { - for i, v := range m.messages { - if v.ID == msg.Payload.ID { - m.messages[i] = msg.Payload - delete(m.cachedContent, msg.Payload.ID) - needsRerender = true - break - } - } - } - if needsRerender { - m.renderView() - if len(m.messages) > 0 { - if (msg.Type == pubsub.CreatedEvent) || - (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { - m.viewport.GotoBottom() - } - } - } - } - - spinner, cmd := m.spinner.Update(msg) - m.spinner = spinner - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -func (m *messagesCmp) IsAgentWorking() bool { - return m.app.CoderAgent.IsSessionBusy(m.session.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) - pos := 0 - baseStyle := styles.BaseStyle() - - if m.width == 0 { - return - } - for inx, msg := range m.messages { - switch msg.Role { - case message.User: - if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { - m.uiMessages = append(m.uiMessages, cache.content...) - continue - } - userMsg := renderUserMessage( - msg, - msg.ID == m.currentMsgID, - m.width, - pos, - ) - m.uiMessages = append(m.uiMessages, userMsg) - m.cachedContent[msg.ID] = cacheItem{ - width: m.width, - content: []uiMessage{userMsg}, - } - pos += userMsg.height + 1 // + 1 for spacing - case message.Assistant: - if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { - m.uiMessages = append(m.uiMessages, cache.content...) - continue - } - isSummary := m.session.SummaryMessageID == msg.ID - - assistantMessages := renderAssistantMessage( - msg, - inx, - m.messages, - m.app.Messages, - m.currentMsgID, - isSummary, - m.width, - pos, - ) - 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, - } - } - } - - messages := make([]string, 0) - for _, v := range m.uiMessages { - messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), - baseStyle. - Width(m.width). - Render( - "", - ), - ) - } - - m.viewport.SetContent( - baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - messages..., - ), - ), - ) -} - -func (m *messagesCmp) View() string { - baseStyle := styles.BaseStyle() - - if m.rendering { - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - "Loading...", - m.working(), - m.help(), - ), - ) - } - if len(m.messages) == 0 { - content := baseStyle. - Width(m.width). - Height(m.height - 1). - Render( - initialScreen(), - ) - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - content, - "", - m.help(), - ), - ) - } - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.viewport.View(), - m.working(), - m.help(), - ), - ) -} - -func hasToolsWithoutResponse(messages []message.Message) bool { - toolCalls := make([]message.ToolCall, 0) - toolResults := make([]message.ToolResult, 0) - for _, m := range messages { - toolCalls = append(toolCalls, m.ToolCalls()...) - toolResults = append(toolResults, m.ToolResults()...) - } - - for _, v := range toolCalls { - found := false - for _, r := range toolResults { - if v.ID == r.ToolCallID { - found = true - break - } - } - if !found && v.Finished { - return true - } - } - return false -} - -func hasUnfinishedToolCalls(messages []message.Message) bool { - toolCalls := make([]message.ToolCall, 0) - for _, m := range messages { - toolCalls = append(toolCalls, m.ToolCalls()...) - } - for _, v := range toolCalls { - if !v.Finished { - return true - } - } - return false -} - -func (m *messagesCmp) working() string { - text := "" - if m.IsAgentWorking() && len(m.messages) > 0 { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - task := "Thinking..." - lastMessage := m.messages[len(m.messages)-1] - if hasToolsWithoutResponse(m.messages) { - task = "Waiting for tool response..." - } else if hasUnfinishedToolCalls(m.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 { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - text := "" - - if m.app.CoderAgent.IsBusy() { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), - baseStyle.Foreground(t.Text()).Bold(true).Render("esc"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"), - ) - } else { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), - baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"), - baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"), - ) - } - return baseStyle. - Width(m.width). - Render(text) -} - -func (m *messagesCmp) rerender() { - for _, msg := range m.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 - } - m.width = width - m.height = height - m.viewport.SetWidth(width) - m.viewport.SetHeight(height - 2) - m.attachments.SetWidth(width + 40) - m.attachments.SetHeight(3) - m.rerender() - return nil -} - -func (m *messagesCmp) GetSize() (int, int) { - return m.width, m.height -} - -func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { - if m.session.ID == session.ID { - return nil - } - m.session = session - messages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - m.messages = messages - if len(m.messages) > 0 { - m.currentMsgID = m.messages[len(m.messages)-1].ID - } - delete(m.cachedContent, m.currentMsgID) - m.rendering = true - return func() tea.Msg { - m.renderView() - return renderFinishedMsg{} - } -} - -func (m *messagesCmp) BindingKeys() []key.Binding { - return []key.Binding{ - m.viewport.KeyMap.PageDown, - m.viewport.KeyMap.PageUp, - m.viewport.KeyMap.HalfPageUp, - m.viewport.KeyMap.HalfPageDown, - } -} - -func NewMessagesCmp(app *app.App) util.Model { - s := spinner.New() - s.Spinner = spinner.Pulse - vp := viewport.New() - attachmets := viewport.New() - 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, - } -} +// +// func (m *messagesCmp) Init() tea.Cmd { +// return tea.Batch(m.viewport.Init(), m.spinner.Tick) +// } +// +// func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// var cmds []tea.Cmd +// switch msg := msg.(type) { +// case dialog.ThemeChangedMsg: +// m.rerender() +// return m, nil +// case SessionSelectedMsg: +// if msg.ID != m.session.ID { +// cmd := m.SetSession(msg) +// return m, cmd +// } +// return m, nil +// case SessionClearedMsg: +// m.session = session.Session{} +// m.messages = make([]message.Message, 0) +// m.currentMsgID = "" +// m.rendering = false +// return m, nil +// +// case tea.KeyMsg: +// 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 +// cmds = append(cmds, cmd) +// } +// +// case renderFinishedMsg: +// m.rendering = false +// m.viewport.GotoBottom() +// case pubsub.Event[session.Session]: +// if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID { +// m.session = msg.Payload +// if m.session.SummaryMessageID == m.currentMsgID { +// delete(m.cachedContent, m.currentMsgID) +// m.renderView() +// } +// } +// case pubsub.Event[message.Message]: +// needsRerender := false +// if msg.Type == pubsub.CreatedEvent { +// if msg.Payload.SessionID == m.session.ID { +// +// messageExists := false +// for _, v := range m.messages { +// if v.ID == msg.Payload.ID { +// messageExists = true +// break +// } +// } +// +// if !messageExists { +// if len(m.messages) > 0 { +// lastMsgID := m.messages[len(m.messages)-1].ID +// delete(m.cachedContent, lastMsgID) +// } +// +// m.messages = append(m.messages, msg.Payload) +// delete(m.cachedContent, m.currentMsgID) +// m.currentMsgID = msg.Payload.ID +// needsRerender = true +// } +// } +// // There are tool calls from the child task +// for _, v := range m.messages { +// for _, c := range v.ToolCalls() { +// if c.ID == msg.Payload.SessionID { +// delete(m.cachedContent, v.ID) +// needsRerender = true +// } +// } +// } +// } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { +// for i, v := range m.messages { +// if v.ID == msg.Payload.ID { +// m.messages[i] = msg.Payload +// delete(m.cachedContent, msg.Payload.ID) +// needsRerender = true +// break +// } +// } +// } +// if needsRerender { +// m.renderView() +// if len(m.messages) > 0 { +// if (msg.Type == pubsub.CreatedEvent) || +// (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { +// m.viewport.GotoBottom() +// } +// } +// } +// } +// +// spinner, cmd := m.spinner.Update(msg) +// m.spinner = spinner +// cmds = append(cmds, cmd) +// return m, tea.Batch(cmds...) +// } +// +// func (m *messagesCmp) IsAgentWorking() bool { +// return m.app.CoderAgent.IsSessionBusy(m.session.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) +// pos := 0 +// baseStyle := styles.BaseStyle() +// +// if m.width == 0 { +// return +// } +// for inx, msg := range m.messages { +// switch msg.Role { +// case message.User: +// if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { +// m.uiMessages = append(m.uiMessages, cache.content...) +// continue +// } +// userMsg := renderUserMessage( +// msg, +// msg.ID == m.currentMsgID, +// m.width, +// pos, +// ) +// m.uiMessages = append(m.uiMessages, userMsg) +// m.cachedContent[msg.ID] = cacheItem{ +// width: m.width, +// content: []uiMessage{userMsg}, +// } +// pos += userMsg.height + 1 // + 1 for spacing +// case message.Assistant: +// if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { +// m.uiMessages = append(m.uiMessages, cache.content...) +// continue +// } +// isSummary := m.session.SummaryMessageID == msg.ID +// +// assistantMessages := renderAssistantMessage( +// msg, +// inx, +// m.messages, +// m.app.Messages, +// m.currentMsgID, +// isSummary, +// m.width, +// pos, +// ) +// 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, +// } +// } +// } +// +// messages := make([]string, 0) +// for _, v := range m.uiMessages { +// messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), +// baseStyle. +// Width(m.width). +// Render( +// "", +// ), +// ) +// } +// +// m.viewport.SetContent( +// baseStyle. +// Width(m.width). +// Render( +// lipgloss.JoinVertical( +// lipgloss.Top, +// messages..., +// ), +// ), +// ) +// } +// +// func (m *messagesCmp) View() string { +// baseStyle := styles.BaseStyle() +// +// if m.rendering { +// return baseStyle. +// Width(m.width). +// Render( +// lipgloss.JoinVertical( +// lipgloss.Top, +// "Loading...", +// m.working(), +// m.help(), +// ), +// ) +// } +// if len(m.messages) == 0 { +// content := baseStyle. +// Width(m.width). +// Height(m.height - 1). +// Render( +// initialScreen(), +// ) +// +// return baseStyle. +// Width(m.width). +// Render( +// lipgloss.JoinVertical( +// lipgloss.Top, +// content, +// "", +// m.help(), +// ), +// ) +// } +// +// return baseStyle. +// Width(m.width). +// Render( +// lipgloss.JoinVertical( +// lipgloss.Top, +// m.viewport.View(), +// m.working(), +// m.help(), +// ), +// ) +// } +// +// func hasToolsWithoutResponse(messages []message.Message) bool { +// toolCalls := make([]message.ToolCall, 0) +// toolResults := make([]message.ToolResult, 0) +// for _, m := range messages { +// toolCalls = append(toolCalls, m.ToolCalls()...) +// toolResults = append(toolResults, m.ToolResults()...) +// } +// +// for _, v := range toolCalls { +// found := false +// for _, r := range toolResults { +// if v.ID == r.ToolCallID { +// found = true +// break +// } +// } +// if !found && v.Finished { +// return true +// } +// } +// return false +// } +// +// func hasUnfinishedToolCalls(messages []message.Message) bool { +// toolCalls := make([]message.ToolCall, 0) +// for _, m := range messages { +// toolCalls = append(toolCalls, m.ToolCalls()...) +// } +// for _, v := range toolCalls { +// if !v.Finished { +// return true +// } +// } +// return false +// } +// +// func (m *messagesCmp) working() string { +// text := "" +// if m.IsAgentWorking() && len(m.messages) > 0 { +// t := theme.CurrentTheme() +// baseStyle := styles.BaseStyle() +// +// task := "Thinking..." +// lastMessage := m.messages[len(m.messages)-1] +// if hasToolsWithoutResponse(m.messages) { +// task = "Waiting for tool response..." +// } else if hasUnfinishedToolCalls(m.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 { +// t := theme.CurrentTheme() +// baseStyle := styles.BaseStyle() +// +// text := "" +// +// if m.app.CoderAgent.IsBusy() { +// text += lipgloss.JoinHorizontal( +// lipgloss.Left, +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), +// baseStyle.Foreground(t.Text()).Bold(true).Render("esc"), +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"), +// ) +// } else { +// text += lipgloss.JoinHorizontal( +// lipgloss.Left, +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), +// baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"), +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"), +// baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"), +// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"), +// ) +// } +// return baseStyle. +// Width(m.width). +// Render(text) +// } +// +// func (m *messagesCmp) rerender() { +// for _, msg := range m.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 +// } +// m.width = width +// m.height = height +// m.viewport.SetWidth(width) +// m.viewport.SetHeight(height - 2) +// m.attachments.SetWidth(width + 40) +// m.attachments.SetHeight(3) +// m.rerender() +// return nil +// } +// +// func (m *messagesCmp) GetSize() (int, int) { +// return m.width, m.height +// } +// +// func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { +// if m.session.ID == session.ID { +// return nil +// } +// m.session = session +// messages, err := m.app.Messages.List(context.Background(), session.ID) +// if err != nil { +// return util.ReportError(err) +// } +// m.messages = messages +// if len(m.messages) > 0 { +// m.currentMsgID = m.messages[len(m.messages)-1].ID +// } +// delete(m.cachedContent, m.currentMsgID) +// m.rendering = true +// return func() tea.Msg { +// m.renderView() +// return renderFinishedMsg{} +// } +// } +// +// func (m *messagesCmp) BindingKeys() []key.Binding { +// return []key.Binding{ +// m.viewport.KeyMap.PageDown, +// m.viewport.KeyMap.PageUp, +// m.viewport.KeyMap.HalfPageUp, +// m.viewport.KeyMap.HalfPageDown, +// } +// } +// +// func NewMessagesCmp(app *app.App) util.Model { +// s := spinner.New() +// s.Spinner = spinner.Pulse +// vp := viewport.New() +// attachmets := viewport.New() +// 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, +// } +// } diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go index d2ad3e4e95d0d53899b662037b14c75c27e221f2..cc3e33db8a814e382d94fa6b2db4bcc9bb935cc8 100644 --- a/internal/tui/components/chat/list_v2.go +++ b/internal/tui/components/chat/list_v2.go @@ -106,6 +106,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) return m.handleNewUserMessage(event.Payload) case message.Assistant: return m.handleNewAssistantMessage(event.Payload) + case message.Tool: + return m.handleToolMessage(event.Payload) } // TODO: handle tools case pubsub.UpdatedEvent: @@ -119,6 +121,10 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd { return m.listCmp.AppendItem(messages.NewMessageCmp(msg)) } +func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { + return nil +} + func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd { // Simple update the content items := m.listCmp.Items() @@ -136,6 +142,8 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)), ), ) + } else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" { + m.listCmp.DeleteItem(len(items) - 1) } return nil } diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go deleted file mode 100644 index fa96c54fb27af23341b425bfccc88d2bcdaa1322..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/message.go +++ /dev/null @@ -1,629 +0,0 @@ -package chat - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/llm/agent" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -type uiMessageType int - -const ( - userMessageType uiMessageType = iota - assistantMessageType - toolMessageType - - maxResultHeight = 10 -) - -type uiMessage struct { - ID string - messageType uiMessageType - position int - height int - content string -} - -func toMarkdown(content string, focused bool, width int) string { - r := styles.GetMarkdownRenderer(width) - rendered, _ := r.Render(content) - return rendered -} - -func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string { - t := theme.CurrentTheme() - - style := styles.BaseStyle(). - Width(width - 1). - BorderLeft(true). - Foreground(t.TextMuted()). - BorderForeground(t.Primary()). - BorderStyle(lipgloss.ThickBorder()) - - if isUser { - style = style.BorderForeground(t.Secondary()) - } - - // Apply markdown formatting and handle background color - parts := []string{ - toMarkdown(msg, isFocused, width), - } - - // 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 message.Message, isFocused bool, width int, position int) uiMessage { - var styledAttachments []string - t := theme.CurrentTheme() - attachmentStyles := styles.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)) - } - content := "" - if len(styledAttachments) > 0 { - attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)) - content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent) - } else { - content = renderMessage(msg.Content().String(), true, isFocused, width) - } - userMsg := uiMessage{ - ID: msg.ID, - messageType: userMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - } - return userMsg -} - -// 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, - isSummary bool, - width int, - position int, -) []uiMessage { - messages := []uiMessage{} - content := msg.Content().String() - thinking := msg.IsThinking() - thinkingContent := msg.ReasoningContent().Thinking - finished := msg.IsFinished() - finishData := msg.FinishPart() - info := []string{} - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Add finish info if available - if finished { - switch finishData.Reason { - case message.FinishReasonEndTurn: - took := formatTimestampDiff(msg.CreatedAt, finishData.Time) - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)), - ) - case message.FinishReasonCanceled: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")), - ) - case message.FinishReasonError: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")), - ) - case message.FinishReasonPermissionDenied: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")), - ) - } - } - if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) { - if content == "" { - content = "*Finished without output*" - } - if isSummary { - info = append(info, baseStyle.Width(width-1).Foreground(t.TextMuted()).Render(" (summary)")) - } - - 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 - content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width) - } - - 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 - } - return messages -} - -func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult { - for _, msg := range futureMessages { - for _, result := range msg.ToolResults() { - if result.ToolCallID == toolCallID { - return &result - } - } - } - return nil -} - -func toolName(name string) string { - switch name { - case agent.AgentToolName: - return "Task" - case tools.BashToolName: - return "Bash" - case tools.EditToolName: - return "Edit" - case tools.FetchToolName: - return "Fetch" - case tools.GlobToolName: - return "Glob" - case tools.GrepToolName: - return "Grep" - case tools.LSToolName: - return "List" - case tools.SourcegraphToolName: - return "Sourcegraph" - case tools.ViewToolName: - return "View" - case tools.WriteToolName: - return "Write" - case tools.PatchToolName: - return "Patch" - } - return name -} - -func getToolAction(name string) string { - switch name { - case agent.AgentToolName: - return "Preparing prompt..." - case tools.BashToolName: - return "Building command..." - case tools.EditToolName: - return "Preparing edit..." - case tools.FetchToolName: - return "Writing fetch..." - case tools.GlobToolName: - return "Finding files..." - case tools.GrepToolName: - return "Searching content..." - case tools.LSToolName: - return "Listing directory..." - case tools.SourcegraphToolName: - return "Searching code..." - case tools.ViewToolName: - return "Reading file..." - case tools.WriteToolName: - return "Preparing write..." - case tools.PatchToolName: - return "Preparing patch..." - } - return "Working..." -} - -func renderToolParams(paramWidth int, toolCall message.ToolCall) string { - params := "" - switch toolCall.Name { - case agent.AgentToolName: - var params agent.AgentParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - prompt := strings.ReplaceAll(params.Prompt, "\n", " ") - return renderParams(paramWidth, prompt) - case tools.BashToolName: - var params tools.BashParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - command := strings.ReplaceAll(params.Command, "\n", " ") - return renderParams(paramWidth, command) - case tools.EditToolName: - var params tools.EditParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - return renderParams(paramWidth, filePath) - case tools.FetchToolName: - var params tools.FetchParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - url := params.URL - toolParams := []string{ - url, - } - if params.Format != "" { - toolParams = append(toolParams, "format", params.Format) - } - if params.Timeout != 0 { - toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String()) - } - return renderParams(paramWidth, toolParams...) - case tools.GlobToolName: - var params tools.GlobParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - pattern := params.Pattern - toolParams := []string{ - pattern, - } - if params.Path != "" { - toolParams = append(toolParams, "path", params.Path) - } - return renderParams(paramWidth, toolParams...) - case tools.GrepToolName: - var params tools.GrepParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - pattern := params.Pattern - toolParams := []string{ - pattern, - } - if params.Path != "" { - toolParams = append(toolParams, "path", params.Path) - } - if params.Include != "" { - toolParams = append(toolParams, "include", params.Include) - } - if params.LiteralText { - toolParams = append(toolParams, "literal", "true") - } - return renderParams(paramWidth, toolParams...) - case tools.LSToolName: - var params tools.LSParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - path := params.Path - if path == "" { - path = "." - } - return renderParams(paramWidth, path) - case tools.SourcegraphToolName: - var params tools.SourcegraphParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - return renderParams(paramWidth, params.Query) - case tools.ViewToolName: - var params tools.ViewParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - toolParams := []string{ - filePath, - } - if params.Limit != 0 { - toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) - } - if params.Offset != 0 { - toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) - } - return renderParams(paramWidth, toolParams...) - case tools.WriteToolName: - var params tools.WriteParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - return renderParams(paramWidth, filePath) - default: - input := strings.ReplaceAll(toolCall.Input, "\n", " ") - params = renderParams(paramWidth, input) - } - return params -} - -func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if response.IsError { - errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " ")) - errContent = ansi.Truncate(errContent, width-1, "...") - return baseStyle. - Width(width). - Foreground(t.Error()). - Render(errContent) - } - - resultContent := truncateHeight(response.Content, maxResultHeight) - switch toolCall.Name { - case agent.AgentToolName: - return toMarkdown(resultContent, false, width) - case tools.BashToolName: - resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent) - return toMarkdown(resultContent, true, width) - case tools.EditToolName: - metadata := tools.EditResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - truncDiff := truncateHeight(metadata.Diff, maxResultHeight) - formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width)) - return formattedDiff - case tools.FetchToolName: - var params tools.FetchParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - mdFormat := "markdown" - switch params.Format { - case "text": - mdFormat = "text" - case "html": - mdFormat = "html" - } - resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent) - return toMarkdown(resultContent, true, width) - case tools.GlobToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.GrepToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.LSToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.SourcegraphToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.ViewToolName: - metadata := tools.ViewResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - ext := filepath.Ext(metadata.FilePath) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight)) - return toMarkdown(resultContent, true, width) - case tools.WriteToolName: - params := tools.WriteParams{} - json.Unmarshal([]byte(toolCall.Input), ¶ms) - metadata := tools.WriteResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - ext := filepath.Ext(params.FilePath) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight)) - return toMarkdown(resultContent, true, width) - default: - resultContent = fmt.Sprintf("```text\n%s\n```", resultContent) - return toMarkdown(resultContent, true, width) - } -} - -func renderToolMessage( - toolCall message.ToolCall, - allMessages []message.Message, - messagesService message.Service, - focusedUIMessageId string, - nested bool, - width int, - position int, -) uiMessage { - if nested { - width = width - 3 - } - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - style := baseStyle. - Width(width - 1). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - BorderForeground(t.TextMuted()) - - response := findToolResponse(toolCall.ID, allMessages) - toolNameText := baseStyle.Foreground(t.TextMuted()). - Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) - - if !toolCall.Finished { - // Get a brief description of what the tool is doing - toolAction := getToolAction(toolCall.Name) - - progressText := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - 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 - } - - params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall) - responseContent := "" - if response != nil { - responseContent = renderToolResponse(toolCall, *response, width-2) - responseContent = strings.TrimSuffix(responseContent, "\n") - } else { - responseContent = baseStyle. - Italic(true). - Width(width - 2). - Foreground(t.TextMuted()). - Render("Waiting for response...") - } - - parts := []string{} - if !nested { - formattedParams := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - Render(params) - - parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams)) - } else { - prefix := baseStyle. - Foreground(t.TextMuted()). - Render(" └ ") - formattedParams := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - Render(params) - parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams)) - } - - if toolCall.Name == agent.AgentToolName { - taskMessages, _ := messagesService.List(context.Background(), toolCall.ID) - toolCalls := []message.ToolCall{} - for _, v := range taskMessages { - toolCalls = append(toolCalls, v.ToolCalls()...) - } - for _, call := range toolCalls { - rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0) - parts = append(parts, rendered.content) - } - } - if responseContent != "" && !nested { - parts = append(parts, responseContent) - } - - content := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ), - ) - if nested { - content = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - } - toolMsg := uiMessage{ - messageType: toolMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - } - return toolMsg -} - -func removeWorkingDirPrefix(path string) string { - wd := config.WorkingDirectory() - path = strings.TrimPrefix(path, wd) - return path -} - -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 renderParams(paramsWidth int, params ...string) string { - if len(params) == 0 { - return "" - } - mainParam := params[0] - if len(mainParam) > paramsWidth { - mainParam = mainParam[:paramsWidth-3] + "..." - } - - if len(params) == 1 { - return mainParam - } - otherParams := params[1:] - // create pairs of key/value - // if odd number of params, the last one is a key without value - if len(otherParams)%2 != 0 { - otherParams = append(otherParams, "") - } - parts := make([]string, 0, len(otherParams)/2) - for i := 0; i < len(otherParams); i += 2 { - key := otherParams[i] - value := otherParams[i+1] - if value == "" { - continue - } - parts = append(parts, fmt.Sprintf("%s=%s", key, value)) - } - - partsRendered := strings.Join(parts, ", ") - remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()" - if remainingWidth < 30 { - // No space for the params, just show the main - return mainParam - } - - if len(parts) > 0 { - mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) - } - - return ansi.Truncate(mainParam, paramsWidth, "...") -} - -// Helper function to format the time difference between two Unix timestamps -func formatTimestampDiff(start, end int64) string { - diffSeconds := float64(end-start) / 1000.0 // Convert to seconds - if diffSeconds < 1 { - return fmt.Sprintf("%dms", int(diffSeconds*1000)) - } - if diffSeconds < 60 { - return fmt.Sprintf("%.1fs", diffSeconds) - } - return fmt.Sprintf("%.1fm", diffSeconds/60) -} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 8b6ab7cf8ace8743a4164c1ca8de28d6d5058ad0..96abe76731a3c2941f56d68911df1048ffaf08ca 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -492,6 +492,7 @@ func (m *model) rerenderItem(inx int) { } // check if the item is in the content start := cachedItem.start + logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height) end := start + cachedItem.height totalLines := len(m.renderedLines) if m.reverse { @@ -504,6 +505,9 @@ func (m *model) rerenderItem(inx int) { } // TODO: if hight changed do something if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 { + if inx == len(m.items)-1 { + m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight()) + } } m.renderedItems.Store(inx, renderedItem{ lines: rerenderedLines, @@ -541,7 +545,12 @@ func (m *model) decreaseOffset(n int) { // UpdateItem implements List. func (m *model) UpdateItem(inx int, item util.Model) { m.items[inx] = item - m.rerenderItem(inx) + if m.selectedItemInx == inx { + if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok { + i.Focus() + } + } + m.ResetView() m.needsRerender = true } @@ -614,6 +623,10 @@ func (m *model) AppendItem(item util.Model) tea.Cmd { func (m *model) DeleteItem(i int) { m.items = slices.Delete(m.items, i, i+1) m.renderedItems.Delete(i) + if m.selectedItemInx == i { + m.selectedItemInx-- + } + m.ResetView() m.needsRerender = true }