diff options
| author | Kujtim Hoxha <[email protected]> | 2025-04-18 20:17:38 +0200 |
|---|---|---|
| committer | Kujtim Hoxha <[email protected]> | 2025-04-21 13:42:27 +0200 |
| commit | 333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f (patch) | |
| tree | e0d456417368e8716c81ee43b82be3d6ed39c59e /internal/tui/components | |
| parent | 05d0e86f10369fd0e51a924ac88029fb92591499 (diff) | |
| download | opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.tar.gz opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.zip | |
implement patch, update ui, improve rendering
Diffstat (limited to 'internal/tui/components')
| -rw-r--r-- | internal/tui/components/chat/editor.go | 58 | ||||
| -rw-r--r-- | internal/tui/components/chat/list.go | 463 | ||||
| -rw-r--r-- | internal/tui/components/chat/message.go | 561 | ||||
| -rw-r--r-- | internal/tui/components/chat/messages.go | 742 | ||||
| -rw-r--r-- | internal/tui/components/chat/sidebar.go | 94 | ||||
| -rw-r--r-- | internal/tui/components/core/status.go | 24 | ||||
| -rw-r--r-- | internal/tui/components/dialog/permission.go | 16 | ||||
| -rw-r--r-- | internal/tui/components/dialog/session.go | 224 | ||||
| -rw-r--r-- | internal/tui/components/logs/details.go | 16 | ||||
| -rw-r--r-- | internal/tui/components/logs/table.go | 3 |
10 files changed, 1401 insertions, 800 deletions
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index ded0639bb..537ef392c 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -1,6 +1,9 @@ package chat import ( + "os" + "os/exec" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" @@ -19,13 +22,15 @@ type editorCmp struct { } type focusedEditorKeyMaps struct { - Send key.Binding - Blur key.Binding + Send key.Binding + OpenEditor key.Binding + Blur key.Binding } type bluredEditorKeyMaps struct { - Send key.Binding - Focus key.Binding + Send key.Binding + Focus key.Binding + OpenEditor key.Binding } var focusedKeyMaps = focusedEditorKeyMaps{ @@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{ key.WithKeys("esc"), key.WithHelp("esc", "focus messages"), ), + OpenEditor: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), } var bluredKeyMaps = bluredEditorKeyMaps{ @@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{ key.WithKeys("i"), key.WithHelp("i", "focus editor"), ), + OpenEditor: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), +} + +func openEditor() tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nvim" + } + + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return util.ReportError(err) + } + tmpfile.Close() + c := exec.Command(editor, tmpfile.Name()) //nolint:gosec + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return util.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return util.ReportError(err) + } + os.Remove(tmpfile.Name()) + return SendMsg{ + Text: string(content), + } + }) } func (m *editorCmp) Init() tea.Cmd { @@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyMsg: + if key.Matches(msg, focusedKeyMaps.OpenEditor) { + m.textarea.Blur() + return m, openEditor() + } // if the key does not match any binding, return if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) { return m, m.send() @@ -108,9 +155,10 @@ func (m *editorCmp) View() string { return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) } -func (m *editorCmp) SetSize(width, height int) { +func (m *editorCmp) SetSize(width, height int) tea.Cmd { m.textarea.SetWidth(width - 3) // account for the prompt and padding right m.textarea.SetHeight(height) + return nil } func (m *editorCmp) GetSize() (int, int) { diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go new file mode 100644 index 000000000..f95b53731 --- /dev/null +++ b/internal/tui/components/chat/list.go @@ -0,0 +1,463 @@ +package chat + +import ( + "context" + "fmt" + "math" + "sync" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kujtimiihoxha/opencode/internal/app" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/message" + "github.com/kujtimiihoxha/opencode/internal/pubsub" + "github.com/kujtimiihoxha/opencode/internal/session" + "github.com/kujtimiihoxha/opencode/internal/tui/layout" + "github.com/kujtimiihoxha/opencode/internal/tui/styles" + "github.com/kujtimiihoxha/opencode/internal/tui/util" +) + +type messagesCmp struct { + app *app.App + width, height int + writingMode bool + viewport viewport.Model + session session.Session + messages []message.Message + uiMessages []uiMessage + currentMsgID string + mutex sync.Mutex + cachedContent map[string][]uiMessage + spinner spinner.Model + rendering bool +} +type renderFinishedMsg struct{} + +func (m *messagesCmp) Init() tea.Cmd { + return tea.Batch(m.viewport.Init()) +} + +func (m *messagesCmp) preloadSessions() tea.Cmd { + return func() tea.Msg { + sessions, err := m.app.Sessions.List(context.Background()) + if err != nil { + return util.ReportError(err)() + } + if len(sessions) == 0 { + return nil + } + if len(sessions) > 20 { + sessions = sessions[:20] + } + for _, s := range sessions { + messages, err := m.app.Messages.List(context.Background(), s.ID) + if err != nil { + return util.ReportError(err)() + } + if len(messages) == 0 { + continue + } + m.cacheSessionMessages(messages, m.width) + + } + logging.Debug("preloaded sessions") + + return nil + } +} + +func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) { + m.mutex.Lock() + defer m.mutex.Unlock() + pos := 0 + if m.width == 0 { + return + } + for inx, msg := range messages { + switch msg.Role { + case message.User: + userMsg := renderUserMessage( + msg, + false, + width, + pos, + ) + m.cachedContent[msg.ID] = []uiMessage{userMsg} + pos += userMsg.height + 1 // + 1 for spacing + case message.Assistant: + assistantMessages := renderAssistantMessage( + msg, + inx, + messages, + m.app.Messages, + "", + width, + pos, + ) + for _, msg := range assistantMessages { + pos += msg.height + 1 // + 1 for spacing + } + m.cachedContent[msg.ID] = assistantMessages + } + } +} + +func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case EditorFocusMsg: + m.writingMode = bool(msg) + 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 renderFinishedMsg: + m.rendering = false + m.viewport.GotoBottom() + case tea.KeyMsg: + if m.writingMode { + return m, nil + } + 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() + } + } + } + } + + u, cmd := m.viewport.Update(msg) + m.viewport = u + cmds = append(cmds, cmd) + + 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 + + if m.width == 0 { + return + } + for inx, msg := range m.messages { + switch msg.Role { + case message.User: + if messages, ok := m.cachedContent[msg.ID]; ok { + m.uiMessages = append(m.uiMessages, messages...) + continue + } + userMsg := renderUserMessage( + msg, + msg.ID == m.currentMsgID, + m.width, + pos, + ) + m.uiMessages = append(m.uiMessages, userMsg) + m.cachedContent[msg.ID] = []uiMessage{userMsg} + pos += userMsg.height + 1 // + 1 for spacing + case message.Assistant: + if messages, ok := m.cachedContent[msg.ID]; ok { + m.uiMessages = append(m.uiMessages, messages...) + continue + } + assistantMessages := renderAssistantMessage( + msg, + inx, + m.messages, + m.app.Messages, + m.currentMsgID, + m.width, + pos, + ) + for _, msg := range assistantMessages { + m.uiMessages = append(m.uiMessages, msg) + pos += msg.height + 1 // + 1 for spacing + } + m.cachedContent[msg.ID] = assistantMessages + } + } + + messages := make([]string, 0) + for _, v := range m.uiMessages { + messages = append(messages, v.content, + styles.BaseStyle. + Width(m.width). + Render( + "", + ), + ) + } + m.viewport.SetContent( + styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + messages..., + ), + ), + ) +} + +func (m *messagesCmp) View() string { + if m.rendering { + return styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + "Loading...", + m.working(), + m.help(), + ), + ) + } + if len(m.messages) == 0 { + content := styles.BaseStyle. + Width(m.width). + Height(m.height - 1). + Render( + m.initialScreen(), + ) + + return styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + content, + "", + m.help(), + ), + ) + } + + return styles.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 { + return true + } + } + + return false +} + +func (m *messagesCmp) working() string { + text := "" + if m.IsAgentWorking() { + task := "Thinking..." + lastMessage := m.messages[len(m.messages)-1] + if hasToolsWithoutResponse(m.messages) { + task = "Waiting for tool response..." + } else if !lastMessage.IsFinished() { + lastUpdate := lastMessage.UpdatedAt + currentTime := time.Now().Unix() + if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 { + task = "Building tool call..." + } else if lastMessage.Content().String() == "" { + task = "Generating..." + } + task = "" + } + if task != "" { + text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render( + fmt.Sprintf("%s %s ", m.spinner.View(), task), + ) + } + } + return text +} + +func (m *messagesCmp) help() string { + text := "" + + if m.writingMode { + text += lipgloss.JoinHorizontal( + lipgloss.Left, + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), + styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"), + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"), + ) + } else { + text += lipgloss.JoinHorizontal( + lipgloss.Left, + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), + styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"), + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"), + ) + } + + return styles.BaseStyle. + Width(m.width). + Render(text) +} + +func (m *messagesCmp) initialScreen() string { + return styles.BaseStyle.Width(m.width).Render( + lipgloss.JoinVertical( + lipgloss.Top, + header(m.width), + "", + lspsConfigured(m.width), + ), + ) +} + +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.Width = width + m.viewport.Height = height - 2 + m.renderView() + return m.preloadSessions() +} + +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.rendering = true + return func() tea.Msg { + m.session = session + messages, err := m.app.Messages.List(context.Background(), session.ID) + if err != nil { + return util.ReportError(err) + } + m.messages = messages + m.currentMsgID = m.messages[len(m.messages)-1].ID + delete(m.cachedContent, m.currentMsgID) + m.renderView() + return renderFinishedMsg{} + } +} + +func (m *messagesCmp) BindingKeys() []key.Binding { + bindings := layout.KeyMapToSlice(m.viewport.KeyMap) + return bindings +} + +func NewMessagesCmp(app *app.App) tea.Model { + s := spinner.New() + s.Spinner = spinner.Pulse + return &messagesCmp{ + app: app, + writingMode: true, + cachedContent: make(map[string][]uiMessage), + viewport: viewport.New(0, 0), + spinner: s, + } +} diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go new file mode 100644 index 000000000..be6c7ce50 --- /dev/null +++ b/internal/tui/components/chat/message.go @@ -0,0 +1,561 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/llm/agent" + "github.com/kujtimiihoxha/opencode/internal/llm/models" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/message" + "github.com/kujtimiihoxha/opencode/internal/tui/styles" +) + +type uiMessageType int + +const ( + userMessageType uiMessageType = iota + assistantMessageType + toolMessageType + + maxResultHeight = 15 +) + +var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false)) + +type uiMessage struct { + ID string + messageType uiMessageType + position int + height int + content string +} + +type renderCache struct { + mutex sync.Mutex + cache map[string][]uiMessage +} + +func toMarkdown(content string, focused bool, width int) string { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(false)), + glamour.WithWordWrap(width), + ) + if focused { + r, _ = glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(true)), + glamour.WithWordWrap(width), + ) + } + rendered, _ := r.Render(content) + return rendered +} + +func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string { + style := styles.BaseStyle. + Width(width - 1). + BorderLeft(true). + Foreground(styles.ForgroundDim). + BorderForeground(styles.PrimaryColor). + BorderStyle(lipgloss.ThickBorder()) + if isUser { + style = style. + BorderForeground(styles.Blue) + } + parts := []string{ + styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.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 message.Message, isFocused bool, width int, position int) uiMessage { + 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, + width int, + position int, +) []uiMessage { + // find the user message that is before this assistant message + var userMsg message.Message + for i := msgIndex - 1; i >= 0; i-- { + msg := allMessages[i] + if msg.Role == message.User { + userMsg = allMessages[i] + break + } + } + + messages := []uiMessage{} + content := msg.Content().String() + finished := msg.IsFinished() + finishData := msg.FinishPart() + info := []string{} + + // Add finish info if available + if finished { + switch finishData.Reason { + case message.FinishReasonEndTurn: + took := formatTimeDifference(userMsg.CreatedAt, finishData.Time) + info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took), + )) + case message.FinishReasonCanceled: + info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"), + )) + case message.FinishReasonError: + info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"), + )) + case message.FinishReasonPermissionDenied: + info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"), + )) + } + } + if content != "" { + content = renderMessage(content, false, msg.ID == focusedUIMessageId, 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 + } + + 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" + } + return name +} + +// renders params, params[0] (params[1]=params[2] ....) +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) - 5 // for the space + 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, "...") +} + +func removeWorkingDirPrefix(path string) string { + wd := config.WorkingDirectory() + if strings.HasPrefix(path, wd) { + path = strings.TrimPrefix(path, wd) + } + if strings.HasPrefix(path, "/") { + path = strings.TrimPrefix(path, "/") + } + if strings.HasPrefix(path, "./") { + path = strings.TrimPrefix(path, "./") + } + if strings.HasPrefix(path, "../") { + path = strings.TrimPrefix(path, "../") + } + return path +} + +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 truncateHeight(content string, height int) string { + lines := strings.Split(content, "\n") + if len(lines) > height { + return strings.Join(lines[:height], "\n") + } + return content +} + +func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string { + if response.IsError { + errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " ")) + errContent = ansi.Truncate(errContent, width-1, "...") + return styles.BaseStyle. + Foreground(styles.Error). + Render(errContent) + } + resultContent := truncateHeight(response.Content, maxResultHeight) + switch toolCall.Name { + case agent.AgentToolName: + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, false, width), + styles.Background, + ) + case tools.BashToolName: + resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + styles.Background, + ) + case tools.EditToolName: + metadata := tools.EditResponseMetadata{} + json.Unmarshal([]byte(response.Metadata), &metadata) + truncDiff := truncateHeight(metadata.Diff, maxResultHeight) + formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle)) + 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 styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + styles.Background, + ) + case tools.GlobToolName: + return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent) + case tools.GrepToolName: + return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent) + case tools.LSToolName: + return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent) + case tools.SourcegraphToolName: + return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).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 styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + styles.Background, + ) + 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 styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + styles.Background, + ) + default: + resultContent = fmt.Sprintf("```text\n%s\n```", resultContent) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + styles.Background, + ) + } +} + +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 + } + response := findToolResponse(toolCall.ID, allMessages) + toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) + params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall) + responseContent := "" + if response != nil { + responseContent = renderToolResponse(toolCall, *response, width-2) + responseContent = strings.TrimSuffix(responseContent, "\n") + } else { + responseContent = styles.BaseStyle. + Italic(true). + Width(width - 2). + Foreground(styles.ForgroundDim). + Render("Waiting for response...") + } + style := styles.BaseStyle. + Width(width - 1). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + PaddingLeft(1). + BorderForeground(styles.ForgroundDim) + + parts := []string{} + if !nested { + params := styles.BaseStyle. + Width(width - 2 - lipgloss.Width(toolName)). + Foreground(styles.ForgroundDim). + Render(params) + + parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params)) + } else { + prefix := styles.BaseStyle. + Foreground(styles.ForgroundDim). + Render(" └ ") + params := styles.BaseStyle. + Width(width - 2 - lipgloss.Width(toolName)). + Foreground(styles.ForgroundMid). + Render(params) + parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params)) + } + 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 +} diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go deleted file mode 100644 index c2ce7d88b..000000000 --- a/internal/tui/components/chat/messages.go +++ /dev/null @@ -1,742 +0,0 @@ -package chat - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/kujtimiihoxha/opencode/internal/app" - "github.com/kujtimiihoxha/opencode/internal/llm/agent" - "github.com/kujtimiihoxha/opencode/internal/llm/models" - "github.com/kujtimiihoxha/opencode/internal/llm/tools" - "github.com/kujtimiihoxha/opencode/internal/logging" - "github.com/kujtimiihoxha/opencode/internal/message" - "github.com/kujtimiihoxha/opencode/internal/pubsub" - "github.com/kujtimiihoxha/opencode/internal/session" - "github.com/kujtimiihoxha/opencode/internal/tui/layout" - "github.com/kujtimiihoxha/opencode/internal/tui/styles" - "github.com/kujtimiihoxha/opencode/internal/tui/util" -) - -type uiMessageType int - -const ( - userMessageType uiMessageType = iota - assistantMessageType - toolMessageType -) - -// messagesTickMsg is a message sent by the timer to refresh messages -type messagesTickMsg time.Time - -type uiMessage struct { - ID string - messageType uiMessageType - position int - height int - content string -} - -type messagesCmp struct { - app *app.App - width, height int - writingMode bool - viewport viewport.Model - session session.Session - messages []message.Message - uiMessages []uiMessage - currentMsgID string - renderer *glamour.TermRenderer - focusRenderer *glamour.TermRenderer - cachedContent map[string]string - spinner spinner.Model - needsRerender bool -} - -func (m *messagesCmp) Init() tea.Cmd { - return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages()) -} - -func (m *messagesCmp) tickMessages() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return messagesTickMsg(t) - }) -} - -func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case messagesTickMsg: - // Refresh messages if we have an active session - if m.session.ID != "" { - messages, err := m.app.Messages.List(context.Background(), m.session.ID) - if err == nil { - m.messages = messages - m.needsRerender = true - } - } - // Continue ticking - cmds = append(cmds, m.tickMessages()) - case EditorFocusMsg: - m.writingMode = bool(msg) - case SessionSelectedMsg: - if msg.ID != m.session.ID { - cmd := m.SetSession(msg) - m.needsRerender = true - return m, cmd - } - return m, nil - case SessionClearedMsg: - m.session = session.Session{} - m.messages = make([]message.Message, 0) - m.currentMsgID = "" - m.needsRerender = true - m.cachedContent = make(map[string]string) - return m, nil - - case tea.KeyMsg: - if m.writingMode { - return m, nil - } - case pubsub.Event[message.Message]: - if msg.Type == pubsub.CreatedEvent { - if msg.Payload.SessionID == m.session.ID { - // check if message exists - - messageExists := false - for _, v := range m.messages { - if v.ID == msg.Payload.ID { - messageExists = true - break - } - } - - if !messageExists { - // If we have messages, ensure the previous last message is not cached - 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 - m.needsRerender = true - } - } - for _, v := range m.messages { - for _, c := range v.ToolCalls() { - if c.ID == msg.Payload.SessionID { - m.needsRerender = true - } - } - } - } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { - logging.Debug("Message", "finish", msg.Payload.FinishReason()) - for i, v := range m.messages { - if v.ID == msg.Payload.ID { - m.messages[i] = msg.Payload - delete(m.cachedContent, msg.Payload.ID) - - // If this is the last message, ensure it's not cached - if i == len(m.messages)-1 { - delete(m.cachedContent, msg.Payload.ID) - } - - m.needsRerender = true - break - } - } - } - } - - oldPos := m.viewport.YPosition - u, cmd := m.viewport.Update(msg) - m.viewport = u - m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos - cmds = append(cmds, cmd) - - spinner, cmd := m.spinner.Update(msg) - m.spinner = spinner - cmds = append(cmds, cmd) - - if m.needsRerender { - m.renderView() - if len(m.messages) > 0 { - if msg, ok := msg.(pubsub.Event[message.Message]); ok { - if (msg.Type == pubsub.CreatedEvent) || - (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { - m.viewport.GotoBottom() - } - } - } - m.needsRerender = false - } - return m, tea.Batch(cmds...) -} - -func (m *messagesCmp) IsAgentWorking() bool { - return m.app.CoderAgent.IsSessionBusy(m.session.ID) -} - -func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string { - // Check if this is the last message in the list - isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID - - // Only use cache for non-last messages - if !isLastMessage { - if v, ok := m.cachedContent[msg.ID]; ok { - return v - } - } - - style := styles.BaseStyle. - Width(m.width). - BorderLeft(true). - Foreground(styles.ForgroundDim). - BorderForeground(styles.ForgroundDim). - BorderStyle(lipgloss.ThickBorder()) - - renderer := m.renderer - if msg.ID == m.currentMsgID { - style = style. - Foreground(styles.Forground). - BorderForeground(styles.Blue). - BorderStyle(lipgloss.ThickBorder()) - renderer = m.focusRenderer - } - c, _ := renderer.Render(msg.Content().String()) - parts := []string{ - styles.ForceReplaceBackgroundWithLipgloss(c, styles.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..., - ), - ) - - // Only cache if it's not the last message - if !isLastMessage { - m.cachedContent[msg.ID] = rendered - } - - return rendered -} - -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) findToolResponse(callID string) *message.ToolResult { - for _, v := range m.messages { - for _, c := range v.ToolResults() { - if c.ToolCallID == callID { - return &c - } - } - } - return nil -} - -func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string { - key := "" - value := "" - result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...") - - response := m.findToolResponse(toolCall.ID) - if response != nil && response.IsError { - // Clean up error message for display by removing newlines - // This ensures error messages display properly in the UI - errMsg := strings.ReplaceAll(response.Content, "\n", " ") - result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "...")) - } else if response != nil { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done") - } - switch toolCall.Name { - // TODO: add result data to the tools - case agent.AgentToolName: - key = "Task" - var params agent.AgentParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = strings.ReplaceAll(params.Prompt, "\n", " ") - if response != nil && !response.IsError { - firstRow := strings.ReplaceAll(response.Content, "\n", " ") - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "...")) - } - case tools.BashToolName: - key = "Bash" - var params tools.BashParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.Command - if response != nil && !response.IsError { - metadata := tools.BashResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime))) - } - - case tools.EditToolName: - key = "Edit" - var params tools.EditParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.FilePath - if response != nil && !response.IsError { - metadata := tools.EditResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals)) - } - case tools.FetchToolName: - key = "Fetch" - var params tools.FetchParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.URL - if response != nil && !response.IsError { - result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content) - } - case tools.GlobToolName: - key = "Glob" - var params tools.GlobParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - if params.Path == "" { - params.Path = "." - } - value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path) - if response != nil && !response.IsError { - metadata := tools.GlobResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - if metadata.Truncated { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles)) - } else { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles)) - } - } - case tools.GrepToolName: - key = "Grep" - var params tools.GrepParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - if params.Path == "" { - params.Path = "." - } - value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path) - if response != nil && !response.IsError { - metadata := tools.GrepResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - if metadata.Truncated { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches)) - } else { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches)) - } - } - case tools.LSToolName: - key = "ls" - var params tools.LSParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - if params.Path == "" { - params.Path = "." - } - value = params.Path - if response != nil && !response.IsError { - metadata := tools.LSResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - if metadata.Truncated { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles)) - } else { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles)) - } - } - case tools.SourcegraphToolName: - key = "Sourcegraph" - var params tools.SourcegraphParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.Query - if response != nil && !response.IsError { - metadata := tools.SourcegraphResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - if metadata.Truncated { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches)) - } else { - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches)) - } - } - case tools.ViewToolName: - key = "View" - var params tools.ViewParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.FilePath - case tools.WriteToolName: - key = "Write" - var params tools.WriteParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - value = params.FilePath - if response != nil && !response.IsError { - metadata := tools.WriteResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - - result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals)) - } - default: - key = toolCall.Name - var params map[string]any - json.Unmarshal([]byte(toolCall.Input), ¶ms) - jsonData, _ := json.Marshal(params) - value = string(jsonData) - } - - style := styles.BaseStyle. - Width(m.width). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - BorderForeground(styles.Yellow) - - keyStyle := styles.BaseStyle. - Foreground(styles.ForgroundDim) - valyeStyle := styles.BaseStyle. - Foreground(styles.Forground) - - if isNested { - valyeStyle = valyeStyle.Foreground(styles.ForgroundMid) - } - keyValye := keyStyle.Render( - fmt.Sprintf("%s: ", key), - ) - if !isNested { - value = valyeStyle. - Render( - ansi.Truncate( - value+" ", - m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result), - "...", - ), - ) - value += result - - } else { - keyValye = keyStyle.Render( - fmt.Sprintf(" └ %s: ", key), - ) - value = valyeStyle. - Width(m.width - lipgloss.Width(keyValye) - 2). - Render( - ansi.Truncate( - value, - m.width-lipgloss.Width(keyValye)-2, - "...", - ), - ) - } - - innerToolCalls := make([]string, 0) - if toolCall.Name == agent.AgentToolName { - messages, _ := m.app.Messages.List(context.Background(), toolCall.ID) - toolCalls := make([]message.ToolCall, 0) - for _, v := range messages { - toolCalls = append(toolCalls, v.ToolCalls()...) - } - for _, v := range toolCalls { - call := m.renderToolCall(v, true) - innerToolCalls = append(innerToolCalls, call) - } - } - - if isNested { - return lipgloss.JoinHorizontal( - lipgloss.Left, - keyValye, - value, - ) - } - callContent := lipgloss.JoinHorizontal( - lipgloss.Left, - keyValye, - value, - ) - callContent = strings.ReplaceAll(callContent, "\n", "") - if len(innerToolCalls) > 0 { - callContent = lipgloss.JoinVertical( - lipgloss.Left, - callContent, - lipgloss.JoinVertical( - lipgloss.Left, - innerToolCalls..., - ), - ) - } - return style.Render(callContent) -} - -func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage { - // find the user message that is before this assistant message - var userMsg message.Message - for i := len(m.messages) - 1; i >= 0; i-- { - if m.messages[i].Role == message.User { - userMsg = m.messages[i] - break - } - } - messages := make([]uiMessage, 0) - if msg.Content().String() != "" { - info := make([]string, 0) - if msg.IsFinished() && msg.FinishReason() == "end_turn" { - finish := msg.FinishPart() - took := formatTimeDifference(userMsg.CreatedAt, finish.Time) - - info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render( - fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took), - )) - } - content := m.renderSimpleMessage(msg, info...) - messages = append(messages, uiMessage{ - messageType: assistantMessageType, - position: 0, // gets updated in renderView - height: lipgloss.Height(content), - content: content, - }) - } - for _, v := range msg.ToolCalls() { - content := m.renderToolCall(v, false) - messages = append(messages, - uiMessage{ - messageType: toolMessageType, - position: 0, // gets updated in renderView - height: lipgloss.Height(content), - content: content, - }, - ) - } - - return messages -} - -func (m *messagesCmp) renderView() { - m.uiMessages = make([]uiMessage, 0) - pos := 0 - - // If we have messages, ensure the last message is not cached - // This ensures we always render the latest content for the most recent message - // which may be actively updating (e.g., during generation) - if len(m.messages) > 0 { - lastMsgID := m.messages[len(m.messages)-1].ID - delete(m.cachedContent, lastMsgID) - } - - // Limit cache to 10 messages - if len(m.cachedContent) > 15 { - // Create a list of keys to delete (oldest messages first) - keys := make([]string, 0, len(m.cachedContent)) - for k := range m.cachedContent { - keys = append(keys, k) - } - // Delete oldest messages until we have 10 or fewer - for i := 0; i < len(keys)-15; i++ { - delete(m.cachedContent, keys[i]) - } - } - - for _, v := range m.messages { - switch v.Role { - case message.User: - content := m.renderSimpleMessage(v) - m.uiMessages = append(m.uiMessages, uiMessage{ - messageType: userMessageType, - position: pos, - height: lipgloss.Height(content), - content: content, - }) - pos += lipgloss.Height(content) + 1 // + 1 for spacing - case message.Assistant: - assistantMessages := m.renderAssistantMessage(v) - for _, msg := range assistantMessages { - msg.position = pos - m.uiMessages = append(m.uiMessages, msg) - pos += msg.height + 1 // + 1 for spacing - } - - } - } - - messages := make([]string, 0) - for _, v := range m.uiMessages { - messages = append(messages, v.content, - styles.BaseStyle. - Width(m.width). - Render( - "", - ), - ) - } - m.viewport.SetContent( - styles.BaseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - messages..., - ), - ), - ) -} - -func (m *messagesCmp) View() string { - if len(m.messages) == 0 { - content := styles.BaseStyle. - Width(m.width). - Height(m.height - 1). - Render( - m.initialScreen(), - ) - - return styles.BaseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - content, - m.help(), - ), - ) - } - - return styles.BaseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.viewport.View(), - m.help(), - ), - ) -} - -func (m *messagesCmp) help() string { - text := "" - - if m.IsAgentWorking() { - text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render( - fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."), - ) - } - if m.writingMode { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), - styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"), - styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"), - ) - } else { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), - styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"), - styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"), - ) - } - - return styles.BaseStyle. - Width(m.width). - Render(text) -} - -func (m *messagesCmp) initialScreen() string { - return styles.BaseStyle.Width(m.width).Render( - lipgloss.JoinVertical( - lipgloss.Top, - header(m.width), - "", - lspsConfigured(m.width), - ), - ) -} - -func (m *messagesCmp) SetSize(width, height int) { - m.width = width - m.height = height - m.viewport.Width = width - m.viewport.Height = height - 1 - focusRenderer, _ := glamour.NewTermRenderer( - glamour.WithStyles(styles.MarkdownTheme(true)), - glamour.WithWordWrap(width-1), - ) - renderer, _ := glamour.NewTermRenderer( - glamour.WithStyles(styles.MarkdownTheme(false)), - glamour.WithWordWrap(width-1), - ) - m.focusRenderer = focusRenderer - // clear the cached content - for k := range m.cachedContent { - delete(m.cachedContent, k) - } - m.renderer = renderer - if len(m.messages) > 0 { - m.renderView() - m.viewport.GotoBottom() - } -} - -func (m *messagesCmp) GetSize() (int, int) { - return m.width, m.height -} - -func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { - m.session = session - messages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - m.messages = messages - m.currentMsgID = m.messages[len(m.messages)-1].ID - m.needsRerender = true - m.cachedContent = make(map[string]string) - return nil -} - -func (m *messagesCmp) BindingKeys() []key.Binding { - bindings := layout.KeyMapToSlice(m.viewport.KeyMap) - return bindings -} - -func NewMessagesCmp(app *app.App) tea.Model { - focusRenderer, _ := glamour.NewTermRenderer( - glamour.WithStyles(styles.MarkdownTheme(true)), - glamour.WithWordWrap(80), - ) - renderer, _ := glamour.NewTermRenderer( - glamour.WithStyles(styles.MarkdownTheme(false)), - glamour.WithWordWrap(80), - ) - - s := spinner.New() - s.Spinner = spinner.Pulse - return &messagesCmp{ - app: app, - writingMode: true, - cachedContent: make(map[string]string), - viewport: viewport.New(0, 0), - focusRenderer: focusRenderer, - renderer: renderer, - spinner: s, - } -} diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index 5a275c0cf..d330e592b 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -51,6 +51,12 @@ func (m *sidebarCmp) Init() tea.Cmd { func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case SessionSelectedMsg: + if msg.ID != m.session.ID { + m.session = msg + ctx := context.Background() + m.loadModifiedFiles(ctx) + } case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { if m.session.ID == msg.Payload.ID { @@ -59,10 +65,16 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case pubsub.Event[history.File]: if msg.Payload.SessionID == m.session.ID { - // When a file changes, reload all modified files - // This ensures we have the complete and accurate list + // Process the individual file change instead of reloading all files ctx := context.Background() - m.loadModifiedFiles(ctx) + m.processFileChanges(ctx, msg.Payload) + + // Return a command to continue receiving events + return m, func() tea.Msg { + ctx := context.Background() + filesCh := m.history.Subscribe(ctx) + return <-filesCh + } } } return m, nil @@ -71,6 +83,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *sidebarCmp) View() string { return styles.BaseStyle. Width(m.width). + PaddingLeft(4). + PaddingRight(2). Height(m.height - 1). Render( lipgloss.JoinVertical( @@ -79,9 +93,9 @@ func (m *sidebarCmp) View() string { " ", m.sessionSection(), " ", - m.modifiedFiles(), - " ", lspsConfigured(m.width), + " ", + m.modifiedFiles(), ), ) } @@ -170,9 +184,10 @@ func (m *sidebarCmp) modifiedFiles() string { ) } -func (m *sidebarCmp) SetSize(width, height int) { +func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { m.width = width m.height = height + return nil } func (m *sidebarCmp) GetSize() (int, int) { @@ -203,6 +218,12 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { return } + // Clear the existing map to rebuild it + m.modFiles = make(map[string]struct { + additions int + removals int + }) + // Process each latest file for _, file := range latestFiles { // Skip if this is the initial version (no changes to show) @@ -250,28 +271,23 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { } func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) { - // Skip if not the latest version + // Skip if this is the initial version (no changes to show) if file.Version == history.InitialVersion { return } - // Get all versions of this file - fileVersions, err := m.history.ListBySession(ctx, m.session.ID) - if err != nil { + // Find the initial version for this file + initialVersion, err := m.findInitialVersion(ctx, file.Path) + if err != nil || initialVersion.ID == "" { return } - // Find the initial version - var initialVersion history.File - for _, v := range fileVersions { - if v.Path == file.Path && v.Version == history.InitialVersion { - initialVersion = v - break - } - } - - // Skip if we can't find the initial version - if initialVersion.ID == "" { + // Skip if content hasn't changed + if initialVersion.Content == file.Content { + // If this file was previously modified but now matches the initial version, + // remove it from the modified files list + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) return } @@ -280,12 +296,7 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) // Only add to modified files if there are changes if additions > 0 || removals > 0 { - // Remove working directory prefix from file path - displayPath := file.Path - workingDir := config.WorkingDirectory() - displayPath = strings.TrimPrefix(displayPath, workingDir) - displayPath = strings.TrimPrefix(displayPath, "/") - + displayPath := getDisplayPath(file.Path) m.modFiles[displayPath] = struct { additions int removals int @@ -293,5 +304,34 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) additions: additions, removals: removals, } + } else { + // If no changes, remove from modified files + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) + } +} + +// Helper function to find the initial version of a file +func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) { + // Get all versions of this file for the session + fileVersions, err := m.history.ListBySession(ctx, m.session.ID) + if err != nil { + return history.File{}, err } + + // Find the initial version + for _, v := range fileVersions { + if v.Path == path && v.Version == history.InitialVersion { + return v, nil + } + } + + return history.File{}, fmt.Errorf("initial version not found") +} + +// Helper function to get the display path for a file +func getDisplayPath(path string) string { + workingDir := config.WorkingDirectory() + displayPath := strings.TrimPrefix(path, workingDir) + return strings.TrimPrefix(displayPath, "/") } diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index e76ecde84..01c535869 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -166,19 +166,31 @@ func (m *statusCmp) projectDiagnostics() string { diagnostics := []string{} if len(errorDiagnostics) > 0 { - errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics))) + errStr := lipgloss.NewStyle(). + Background(styles.BackgroundDarker). + Foreground(styles.Error). + Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics))) diagnostics = append(diagnostics, errStr) } if len(warnDiagnostics) > 0 { - warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics))) + warnStr := lipgloss.NewStyle(). + Background(styles.BackgroundDarker). + Foreground(styles.Warning). + Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics))) diagnostics = append(diagnostics, warnStr) } if len(hintDiagnostics) > 0 { - hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics))) + hintStr := lipgloss.NewStyle(). + Background(styles.BackgroundDarker). + Foreground(styles.Text). + Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics))) diagnostics = append(diagnostics, hintStr) } if len(infoDiagnostics) > 0 { - infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics))) + infoStr := lipgloss.NewStyle(). + Background(styles.BackgroundDarker). + Foreground(styles.Peach). + Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics))) diagnostics = append(diagnostics, infoStr) } @@ -187,10 +199,12 @@ func (m *statusCmp) projectDiagnostics() string { func (m statusCmp) availableFooterMsgWidth(diagnostics string) int { tokens := "" + tokensWidth := 0 if m.session.ID != "" { tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost) + tokensWidth = lipgloss.Width(tokens) + 2 } - return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens)) + return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth) } func (m statusCmp) model() string { diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index 295884432..f83472e68 100644 --- a/internal/tui/components/dialog/permission.go +++ b/internal/tui/components/dialog/permission.go @@ -36,7 +36,7 @@ type PermissionResponseMsg struct { type PermissionDialogCmp interface { tea.Model layout.Bindings - SetPermissions(permission permission.PermissionRequest) + SetPermissions(permission permission.PermissionRequest) tea.Cmd } type permissionsMapping struct { @@ -98,7 +98,8 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: p.windowSize = msg - p.SetSize() + cmd := p.SetSize() + cmds = append(cmds, cmd) p.markdownCache = make(map[string]string) p.diffCache = make(map[string]string) case tea.KeyMsg: @@ -267,7 +268,7 @@ func (p *permissionDialogCmp) renderEditContent() string { } func (p *permissionDialogCmp) renderPatchContent() string { - if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok { + if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width)) }) @@ -401,9 +402,9 @@ func (p *permissionDialogCmp) BindingKeys() []key.Binding { return layout.KeyMapToSlice(helpKeys) } -func (p *permissionDialogCmp) SetSize() { +func (p *permissionDialogCmp) SetSize() tea.Cmd { if p.permission.ID == "" { - return + return nil } switch p.permission.ToolName { case tools.BashToolName: @@ -422,11 +423,12 @@ func (p *permissionDialogCmp) SetSize() { p.width = int(float64(p.windowSize.Width) * 0.7) p.height = int(float64(p.windowSize.Height) * 0.5) } + return nil } -func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) { +func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd { p.permission = permission - p.SetSize() + return p.SetSize() } // Helper to get or set cached diff content diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go new file mode 100644 index 000000000..d8c859c49 --- /dev/null +++ b/internal/tui/components/dialog/session.go @@ -0,0 +1,224 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kujtimiihoxha/opencode/internal/session" + "github.com/kujtimiihoxha/opencode/internal/tui/layout" + "github.com/kujtimiihoxha/opencode/internal/tui/styles" + "github.com/kujtimiihoxha/opencode/internal/tui/util" +) + +// SessionSelectedMsg is sent when a session is selected +type SessionSelectedMsg struct { + Session session.Session +} + +// CloseSessionDialogMsg is sent when the session dialog is closed +type CloseSessionDialogMsg struct{} + +// SessionDialog interface for the session switching dialog +type SessionDialog interface { + tea.Model + layout.Bindings + SetSessions(sessions []session.Session) + SetSelectedSession(sessionID string) +} + +type sessionDialogCmp struct { + sessions []session.Session + selectedIdx int + width int + height int + selectedSessionID string +} + +type sessionKeyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding + J key.Binding + K key.Binding +} + +var sessionKeys = sessionKeyMap{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "previous session"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "next session"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select session"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), + J: key.NewBinding( + key.WithKeys("j"), + key.WithHelp("j", "next session"), + ), + K: key.NewBinding( + key.WithKeys("k"), + key.WithHelp("k", "previous session"), + ), +} + +func (s *sessionDialogCmp) Init() tea.Cmd { + return nil +} + +func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K): + if s.selectedIdx > 0 { + s.selectedIdx-- + } + return s, nil + case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J): + if s.selectedIdx < len(s.sessions)-1 { + s.selectedIdx++ + } + return s, nil + case key.Matches(msg, sessionKeys.Enter): + if len(s.sessions) > 0 { + return s, util.CmdHandler(SessionSelectedMsg{ + Session: s.sessions[s.selectedIdx], + }) + } + case key.Matches(msg, sessionKeys.Escape): + return s, util.CmdHandler(CloseSessionDialogMsg{}) + } + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height + } + return s, nil +} + +func (s *sessionDialogCmp) View() string { + if len(s.sessions) == 0 { + return styles.BaseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(styles.Background). + BorderForeground(styles.ForgroundDim). + Width(40). + Render("No sessions available") + } + + // Calculate max width needed for session titles + maxWidth := 40 // Minimum width + for _, sess := range s.sessions { + if len(sess.Title) > maxWidth-4 { // Account for padding + maxWidth = len(sess.Title) + 4 + } + } + + // Limit height to avoid taking up too much screen space + maxVisibleSessions := min(10, len(s.sessions)) + + // Build the session list + sessionItems := make([]string, 0, maxVisibleSessions) + startIdx := 0 + + // If we have more sessions than can be displayed, adjust the start index + if len(s.sessions) > maxVisibleSessions { + // Center the selected item when possible + halfVisible := maxVisibleSessions / 2 + if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible { + startIdx = s.selectedIdx - halfVisible + } else if s.selectedIdx >= len(s.sessions)-halfVisible { + startIdx = len(s.sessions) - maxVisibleSessions + } + } + + endIdx := min(startIdx+maxVisibleSessions, len(s.sessions)) + + for i := startIdx; i < endIdx; i++ { + sess := s.sessions[i] + itemStyle := styles.BaseStyle.Width(maxWidth) + + if i == s.selectedIdx { + itemStyle = itemStyle. + Background(styles.PrimaryColor). + Foreground(styles.Background). + Bold(true) + } + + sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title)) + } + + title := styles.BaseStyle. + Foreground(styles.PrimaryColor). + Bold(true). + Padding(0, 1). + Render("Switch Session") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + styles.BaseStyle.Render(""), + lipgloss.JoinVertical(lipgloss.Left, sessionItems...), + styles.BaseStyle.Render(""), + styles.BaseStyle.Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"), + ) + + return styles.BaseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(styles.Background). + BorderForeground(styles.ForgroundDim). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (s *sessionDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(sessionKeys) +} + +func (s *sessionDialogCmp) SetSessions(sessions []session.Session) { + s.sessions = sessions + + // If we have a selected session ID, find its index + if s.selectedSessionID != "" { + for i, sess := range sessions { + if sess.ID == s.selectedSessionID { + s.selectedIdx = i + return + } + } + } + + // Default to first session if selected not found + s.selectedIdx = 0 +} + +func (s *sessionDialogCmp) SetSelectedSession(sessionID string) { + s.selectedSessionID = sessionID + + // Update the selected index if sessions are already loaded + if len(s.sessions) > 0 { + for i, sess := range s.sessions { + if sess.ID == sessionID { + s.selectedIdx = i + return + } + } + } +} + +// NewSessionDialogCmp creates a new session switching dialog +func NewSessionDialogCmp() SessionDialog { + return &sessionDialogCmp{ + sessions: []session.Session{}, + selectedIdx: 0, + selectedSessionID: "", + } +}
\ No newline at end of file diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go index 7c74da104..fa49adbbb 100644 --- a/internal/tui/components/logs/details.go +++ b/internal/tui/components/logs/details.go @@ -119,27 +119,17 @@ func (i *detailCmp) GetSize() (int, int) { return i.width, i.height } -func (i *detailCmp) SetSize(width int, height int) { +func (i *detailCmp) SetSize(width int, height int) tea.Cmd { i.width = width i.height = height i.viewport.Width = i.width i.viewport.Height = i.height i.updateContent() + return nil } func (i *detailCmp) BindingKeys() []key.Binding { - return []key.Binding{ - i.viewport.KeyMap.PageDown, - i.viewport.KeyMap.PageUp, - i.viewport.KeyMap.HalfPageDown, - i.viewport.KeyMap.HalfPageUp, - } -} - -func (i *detailCmp) BorderText() map[layout.BorderPosition]string { - return map[layout.BorderPosition]string{ - layout.TopLeftBorder: "Log Details", - } + return layout.KeyMapToSlice(i.viewport.KeyMap) } func NewLogsDetails() DetailComponent { diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go index 2d0f9c533..245714d0d 100644 --- a/internal/tui/components/logs/table.go +++ b/internal/tui/components/logs/table.go @@ -68,7 +68,7 @@ func (i *tableCmp) GetSize() (int, int) { return i.table.Width(), i.table.Height() } -func (i *tableCmp) SetSize(width int, height int) { +func (i *tableCmp) SetSize(width int, height int) tea.Cmd { i.table.SetWidth(width) i.table.SetHeight(height) cloumns := i.table.Columns() @@ -77,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) { cloumns[i] = col } i.table.SetColumns(cloumns) + return nil } func (i *tableCmp) BindingKeys() []key.Binding { |
