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 | |
| parent | 05d0e86f10369fd0e51a924ac88029fb92591499 (diff) | |
| download | opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.tar.gz opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.zip | |
implement patch, update ui, improve rendering
Diffstat (limited to 'internal/tui')
22 files changed, 1624 insertions, 1864 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 { diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go deleted file mode 100644 index c47c4e090..000000000 --- a/internal/tui/layout/bento.go +++ /dev/null @@ -1,392 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type paneID string - -const ( - BentoLeftPane paneID = "left" - BentoRightTopPane paneID = "right-top" - BentoRightBottomPane paneID = "right-bottom" -) - -type BentoPanes map[paneID]tea.Model - -const ( - defaultLeftWidthRatio = 0.2 - defaultRightTopHeightRatio = 0.85 - - minLeftWidth = 10 - minRightBottomHeight = 10 -) - -type BentoLayout interface { - tea.Model - Sizeable - Bindings -} - -type BentoKeyBindings struct { - SwitchPane key.Binding - SwitchPaneBack key.Binding - HideCurrentPane key.Binding - ShowAllPanes key.Binding -} - -var defaultBentoKeyBindings = BentoKeyBindings{ - SwitchPane: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch pane"), - ), - SwitchPaneBack: key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "switch pane back"), - ), - HideCurrentPane: key.NewBinding( - key.WithKeys("X"), - key.WithHelp("X", "hide current pane"), - ), - ShowAllPanes: key.NewBinding( - key.WithKeys("R"), - key.WithHelp("R", "show all panes"), - ), -} - -type bentoLayout struct { - width int - height int - - leftWidthRatio float64 - rightTopHeightRatio float64 - - currentPane paneID - panes map[paneID]SinglePaneLayout - hiddenPanes map[paneID]bool -} - -func (b *bentoLayout) GetSize() (int, int) { - return b.width, b.height -} - -func (b *bentoLayout) Init() tea.Cmd { - var cmds []tea.Cmd - for _, pane := range b.panes { - cmd := pane.Init() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return tea.Batch(cmds...) -} - -func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - return b, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, defaultBentoKeyBindings.SwitchPane): - return b, b.SwitchPane(false) - case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack): - return b, b.SwitchPane(true) - case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane): - return b, b.HidePane(b.currentPane) - case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes): - for id := range b.hiddenPanes { - delete(b.hiddenPanes, id) - } - b.SetSize(b.width, b.height) - return b, nil - } - } - - var cmds []tea.Cmd - for id, pane := range b.panes { - u, cmd := pane.Update(msg) - b.panes[id] = u.(SinglePaneLayout) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return b, tea.Batch(cmds...) -} - -func (b *bentoLayout) View() string { - if b.width <= 0 || b.height <= 0 { - return "" - } - - for id, pane := range b.panes { - if b.currentPane == id { - pane.Focus() - } else { - pane.Blur() - } - } - - leftVisible := false - rightTopVisible := false - rightBottomVisible := false - - var leftPane, rightTopPane, rightBottomPane string - - if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] { - leftPane = pane.View() - leftVisible = true - } - - if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] { - rightTopPane = pane.View() - rightTopVisible = true - } - - if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] { - rightBottomPane = pane.View() - rightBottomVisible = true - } - - if leftVisible { - if rightTopVisible || rightBottomVisible { - rightSection := "" - if rightTopVisible && rightBottomVisible { - rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane) - } else if rightTopVisible { - rightSection = rightTopPane - } else { - rightSection = rightBottomPane - } - return lipgloss.NewStyle().Width(b.width).Height(b.height).Render( - lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection), - ) - } else { - return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane) - } - } else if rightTopVisible || rightBottomVisible { - if rightTopVisible && rightBottomVisible { - return lipgloss.NewStyle().Width(b.width).Height(b.height).Render( - lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane), - ) - } else if rightTopVisible { - return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane) - } else { - return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane) - } - } - return "" -} - -func (b *bentoLayout) SetSize(width int, height int) { - if width < 0 || height < 0 { - return - } - b.width = width - b.height = height - - leftExists := false - rightTopExists := false - rightBottomExists := false - - if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] { - leftExists = true - } - if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] { - rightTopExists = true - } - if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] { - rightBottomExists = true - } - - leftWidth := 0 - rightWidth := 0 - rightTopHeight := 0 - rightBottomHeight := 0 - - if leftExists && (rightTopExists || rightBottomExists) { - leftWidth = int(float64(width) * b.leftWidthRatio) - if leftWidth < minLeftWidth && width >= minLeftWidth { - leftWidth = minLeftWidth - } - rightWidth = width - leftWidth - - if rightTopExists && rightBottomExists { - rightTopHeight = int(float64(height) * b.rightTopHeightRatio) - rightBottomHeight = height - rightTopHeight - - if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight { - rightBottomHeight = minRightBottomHeight - rightTopHeight = height - rightBottomHeight - } - } else if rightTopExists { - rightTopHeight = height - } else if rightBottomExists { - rightBottomHeight = height - } - } else if leftExists { - leftWidth = width - } else if rightTopExists || rightBottomExists { - rightWidth = width - - if rightTopExists && rightBottomExists { - rightTopHeight = int(float64(height) * b.rightTopHeightRatio) - rightBottomHeight = height - rightTopHeight - - if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight { - rightBottomHeight = minRightBottomHeight - rightTopHeight = height - rightBottomHeight - } - } else if rightTopExists { - rightTopHeight = height - } else if rightBottomExists { - rightBottomHeight = height - } - } - - if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] { - pane.SetSize(leftWidth, height) - } - if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] { - pane.SetSize(rightWidth, rightTopHeight) - } - if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] { - pane.SetSize(rightWidth, rightBottomHeight) - } -} - -func (b *bentoLayout) HidePane(pane paneID) tea.Cmd { - if len(b.panes)-len(b.hiddenPanes) == 1 { - return nil - } - if _, ok := b.panes[pane]; ok { - b.hiddenPanes[pane] = true - } - b.SetSize(b.width, b.height) - return b.SwitchPane(false) -} - -func (b *bentoLayout) SwitchPane(back bool) tea.Cmd { - orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane} - orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane} - - order := orderForward - if back { - order = orderBackward - } - - currentIdx := -1 - for i, id := range order { - if id == b.currentPane { - currentIdx = i - break - } - } - - if currentIdx == -1 { - for _, id := range order { - if _, exists := b.panes[id]; exists { - if _, hidden := b.hiddenPanes[id]; !hidden { - b.currentPane = id - break - } - } - } - } else { - startIdx := currentIdx - for { - currentIdx = (currentIdx + 1) % len(order) - - nextID := order[currentIdx] - if _, exists := b.panes[nextID]; exists { - if _, hidden := b.hiddenPanes[nextID]; !hidden { - b.currentPane = nextID - break - } - } - - if currentIdx == startIdx { - break - } - } - } - - var cmds []tea.Cmd - for id, pane := range b.panes { - if _, ok := b.hiddenPanes[id]; ok { - continue - } - if id == b.currentPane { - cmds = append(cmds, pane.Focus()) - } else { - cmds = append(cmds, pane.Blur()) - } - } - - return tea.Batch(cmds...) -} - -func (s *bentoLayout) BindingKeys() []key.Binding { - bindings := KeyMapToSlice(defaultBentoKeyBindings) - if b, ok := s.panes[s.currentPane].(Bindings); ok { - bindings = append(bindings, b.BindingKeys()...) - } - return bindings -} - -type BentoLayoutOption func(*bentoLayout) - -func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout { - p := make(map[paneID]SinglePaneLayout, len(panes)) - for id, pane := range panes { - if sp, ok := pane.(SinglePaneLayout); !ok { - p[id] = NewSinglePane( - pane, - WithSinglePaneFocusable(true), - WithSinglePaneBordered(true), - ) - } else { - p[id] = sp - } - } - if len(p) == 0 { - panic("no panes provided for BentoLayout") - } - layout := &bentoLayout{ - panes: p, - hiddenPanes: make(map[paneID]bool), - currentPane: BentoLeftPane, - leftWidthRatio: defaultLeftWidthRatio, - rightTopHeightRatio: defaultRightTopHeightRatio, - } - - for _, opt := range opts { - opt(layout) - } - - return layout -} - -func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption { - return func(b *bentoLayout) { - if ratio > 0 && ratio < 1 { - b.leftWidthRatio = ratio - } - } -} - -func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption { - return func(b *bentoLayout) { - if ratio > 0 && ratio < 1 { - b.rightTopHeightRatio = ratio - } - } -} - -func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption { - return func(b *bentoLayout) { - b.currentPane = pane - } -} diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go deleted file mode 100644 index ea9f5e0bc..000000000 --- a/internal/tui/layout/border.go +++ /dev/null @@ -1,121 +0,0 @@ -package layout - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/kujtimiihoxha/opencode/internal/tui/styles" -) - -type BorderPosition int - -const ( - TopLeftBorder BorderPosition = iota - TopMiddleBorder - TopRightBorder - BottomLeftBorder - BottomMiddleBorder - BottomRightBorder -) - -var ( - ActiveBorder = styles.Blue - InactivePreviewBorder = styles.Grey -) - -type BorderOptions struct { - Active bool - EmbeddedText map[BorderPosition]string - ActiveColor lipgloss.TerminalColor - InactiveColor lipgloss.TerminalColor - ActiveBorder lipgloss.Border - InactiveBorder lipgloss.Border -} - -func Borderize(content string, opts BorderOptions) string { - if opts.EmbeddedText == nil { - opts.EmbeddedText = make(map[BorderPosition]string) - } - if opts.ActiveColor == nil { - opts.ActiveColor = ActiveBorder - } - if opts.InactiveColor == nil { - opts.InactiveColor = InactivePreviewBorder - } - if opts.ActiveBorder == (lipgloss.Border{}) { - opts.ActiveBorder = lipgloss.ThickBorder() - } - if opts.InactiveBorder == (lipgloss.Border{}) { - opts.InactiveBorder = lipgloss.NormalBorder() - } - - var ( - thickness = map[bool]lipgloss.Border{ - true: opts.ActiveBorder, - false: opts.InactiveBorder, - } - color = map[bool]lipgloss.TerminalColor{ - true: opts.ActiveColor, - false: opts.InactiveColor, - } - border = thickness[opts.Active] - style = lipgloss.NewStyle().Foreground(color[opts.Active]) - width = lipgloss.Width(content) - ) - - encloseInSquareBrackets := func(text string) string { - if text != "" { - return fmt.Sprintf("%s%s%s", - style.Render(border.TopRight), - text, - style.Render(border.TopLeft), - ) - } - return text - } - buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string { - leftText = encloseInSquareBrackets(leftText) - middleText = encloseInSquareBrackets(middleText) - rightText = encloseInSquareBrackets(rightText) - // Calculate length of border between embedded texts - remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText)) - leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2)) - rightBorderLen := max(0, remaining-leftBorderLen) - // Then construct border string - s := leftText + - style.Render(strings.Repeat(inbetween, leftBorderLen)) + - middleText + - style.Render(strings.Repeat(inbetween, rightBorderLen)) + - rightText - // Make it fit in the space available between the two corners. - s = lipgloss.NewStyle(). - Inline(true). - MaxWidth(width). - Render(s) - // Add the corners - return style.Render(leftCorner) + s + style.Render(rightCorner) - } - // Stack top border, content and horizontal borders, and bottom border. - return strings.Join([]string{ - buildHorizontalBorder( - opts.EmbeddedText[TopLeftBorder], - opts.EmbeddedText[TopMiddleBorder], - opts.EmbeddedText[TopRightBorder], - border.TopLeft, - border.Top, - border.TopRight, - ), - lipgloss.NewStyle(). - BorderForeground(color[opts.Active]). - Border(border, false, true, false, true).Render(content), - buildHorizontalBorder( - opts.EmbeddedText[BottomLeftBorder], - opts.EmbeddedText[BottomMiddleBorder], - opts.EmbeddedText[BottomRightBorder], - border.BottomLeft, - border.Bottom, - border.BottomRight, - ), - }, "\n") -} diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go index c86d954ea..fdb9ab403 100644 --- a/internal/tui/layout/container.go +++ b/internal/tui/layout/container.go @@ -86,7 +86,7 @@ func (c *container) View() string { return style.Render(c.content.View()) } -func (c *container) SetSize(width, height int) { +func (c *container) SetSize(width, height int) tea.Cmd { c.width = width c.height = height @@ -113,8 +113,9 @@ func (c *container) SetSize(width, height int) { // Set content size with adjusted dimensions contentWidth := max(0, width-horizontalSpace) contentHeight := max(0, height-verticalSpace) - sizeable.SetSize(contentWidth, contentHeight) + return sizeable.SetSize(contentWidth, contentHeight) } + return nil } func (c *container) GetSize() (int, int) { diff --git a/internal/tui/layout/grid.go b/internal/tui/layout/grid.go deleted file mode 100644 index 6be493e2c..000000000 --- a/internal/tui/layout/grid.go +++ /dev/null @@ -1,254 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type GridLayout interface { - tea.Model - Sizeable - Bindings - Panes() [][]tea.Model -} - -type gridLayout struct { - width int - height int - - rows int - columns int - - panes [][]tea.Model - - gap int - bordered bool - focusable bool - - currentRow int - currentColumn int - - activeColor lipgloss.TerminalColor -} - -type GridOption func(*gridLayout) - -func (g *gridLayout) Init() tea.Cmd { - var cmds []tea.Cmd - for i := range g.panes { - for j := range g.panes[i] { - if g.panes[i][j] != nil { - cmds = append(cmds, g.panes[i][j].Init()) - } - } - } - return tea.Batch(cmds...) -} - -func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - g.SetSize(msg.Width, msg.Height) - return g, nil - case tea.KeyMsg: - if key.Matches(msg, g.nextPaneBinding()) { - return g.focusNextPane() - } - } - - // Update all panes - for i := range g.panes { - for j := range g.panes[i] { - if g.panes[i][j] != nil { - var cmd tea.Cmd - g.panes[i][j], cmd = g.panes[i][j].Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - } - - return g, tea.Batch(cmds...) -} - -func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) { - if !g.focusable { - return g, nil - } - - var cmds []tea.Cmd - - // Blur current pane - if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) { - if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok { - cmds = append(cmds, currentPane.Blur()) - } - } - - // Find next valid pane - g.currentColumn++ - if g.currentColumn >= len(g.panes[g.currentRow]) { - g.currentColumn = 0 - g.currentRow++ - if g.currentRow >= len(g.panes) { - g.currentRow = 0 - } - } - - // Focus next pane - if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) { - if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok { - cmds = append(cmds, nextPane.Focus()) - } - } - - return g, tea.Batch(cmds...) -} - -func (g *gridLayout) nextPaneBinding() key.Binding { - return key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "next pane"), - ) -} - -func (g *gridLayout) View() string { - if len(g.panes) == 0 { - return "" - } - - // Calculate dimensions for each cell - cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns - cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows - - // Render each row - rows := make([]string, g.rows) - for i := range g.rows { - // Render each column in this row - cols := make([]string, len(g.panes[i])) - for j := range g.panes[i] { - if g.panes[i][j] == nil { - cols[j] = "" - continue - } - - // Set size for each pane - if sizable, ok := g.panes[i][j].(Sizeable); ok { - effectiveWidth, effectiveHeight := cellWidth, cellHeight - if g.bordered { - effectiveWidth -= 2 - effectiveHeight -= 2 - } - sizable.SetSize(effectiveWidth, effectiveHeight) - } - - // Render the pane - content := g.panes[i][j].View() - - // Apply border if needed - if g.bordered { - isFocused := false - if focusable, ok := g.panes[i][j].(Focusable); ok { - isFocused = focusable.IsFocused() - } - - borderText := map[BorderPosition]string{} - if bordered, ok := g.panes[i][j].(Bordered); ok { - borderText = bordered.BorderText() - } - - content = Borderize(content, BorderOptions{ - Active: isFocused, - EmbeddedText: borderText, - }) - } - - cols[j] = content - } - - // Join columns with gap - rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...) - } - - // Join rows with gap - return lipgloss.JoinVertical(lipgloss.Left, rows...) -} - -func (g *gridLayout) SetSize(width, height int) { - g.width = width - g.height = height -} - -func (g *gridLayout) GetSize() (int, int) { - return g.width, g.height -} - -func (g *gridLayout) BindingKeys() []key.Binding { - var bindings []key.Binding - bindings = append(bindings, g.nextPaneBinding()) - - // Collect bindings from all panes - for i := range g.panes { - for j := range g.panes[i] { - if g.panes[i][j] != nil { - if bindable, ok := g.panes[i][j].(Bindings); ok { - bindings = append(bindings, bindable.BindingKeys()...) - } - } - } - } - - return bindings -} - -func (g *gridLayout) Panes() [][]tea.Model { - return g.panes -} - -// NewGridLayout creates a new grid layout with the given number of rows and columns -func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout { - grid := &gridLayout{ - rows: rows, - columns: cols, - panes: panes, - gap: 1, - } - - for _, opt := range opts { - opt(grid) - } - - return grid -} - -// WithGridGap sets the gap between cells -func WithGridGap(gap int) GridOption { - return func(g *gridLayout) { - g.gap = gap - } -} - -// WithGridBordered sets whether cells should have borders -func WithGridBordered(bordered bool) GridOption { - return func(g *gridLayout) { - g.bordered = bordered - } -} - -// WithGridFocusable sets whether the grid supports focus navigation -func WithGridFocusable(focusable bool) GridOption { - return func(g *gridLayout) { - g.focusable = focusable - } -} - -// WithGridActiveColor sets the active border color -func WithGridActiveColor(color lipgloss.TerminalColor) GridOption { - return func(g *gridLayout) { - g.activeColor = color - } -} diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go index 2f17c4a0e..495a3fbc5 100644 --- a/internal/tui/layout/layout.go +++ b/internal/tui/layout/layout.go @@ -13,12 +13,8 @@ type Focusable interface { IsFocused() bool } -type Bordered interface { - BorderText() map[BorderPosition]string -} - type Sizeable interface { - SetSize(width, height int) + SetSize(width, height int) tea.Cmd GetSize() (int, int) } diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go deleted file mode 100644 index c77fa0d78..000000000 --- a/internal/tui/layout/single.go +++ /dev/null @@ -1,189 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type SinglePaneLayout interface { - tea.Model - Focusable - Sizeable - Bindings - Pane() tea.Model -} - -type singlePaneLayout struct { - width int - height int - - focusable bool - focused bool - - bordered bool - borderText map[BorderPosition]string - - content tea.Model - - padding []int - - activeColor lipgloss.TerminalColor -} - -type SinglePaneOption func(*singlePaneLayout) - -func (s *singlePaneLayout) Init() tea.Cmd { - return s.content.Init() -} - -func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - s.SetSize(msg.Width, msg.Height) - return s, nil - } - u, cmd := s.content.Update(msg) - s.content = u - return s, cmd -} - -func (s *singlePaneLayout) View() string { - style := lipgloss.NewStyle().Width(s.width).Height(s.height) - if s.bordered { - style = style.Width(s.width - 2).Height(s.height - 2) - } - if s.padding != nil { - style = style.Padding(s.padding...) - } - content := style.Render(s.content.View()) - if s.bordered { - if s.borderText == nil { - s.borderText = map[BorderPosition]string{} - } - if bordered, ok := s.content.(Bordered); ok { - s.borderText = bordered.BorderText() - } - return Borderize(content, BorderOptions{ - Active: s.focused, - EmbeddedText: s.borderText, - }) - } - return content -} - -func (s *singlePaneLayout) Blur() tea.Cmd { - if s.focusable { - s.focused = false - } - if blurable, ok := s.content.(Focusable); ok { - return blurable.Blur() - } - return nil -} - -func (s *singlePaneLayout) Focus() tea.Cmd { - if s.focusable { - s.focused = true - } - if focusable, ok := s.content.(Focusable); ok { - return focusable.Focus() - } - return nil -} - -func (s *singlePaneLayout) SetSize(width, height int) { - s.width = width - s.height = height - childWidth, childHeight := s.width, s.height - if s.bordered { - childWidth -= 2 - childHeight -= 2 - } - if s.padding != nil { - if len(s.padding) == 1 { - childWidth -= s.padding[0] * 2 - childHeight -= s.padding[0] * 2 - } else if len(s.padding) == 2 { - childWidth -= s.padding[0] * 2 - childHeight -= s.padding[1] * 2 - } else if len(s.padding) == 3 { - childWidth -= s.padding[0] * 2 - childHeight -= s.padding[1] + s.padding[2] - } else if len(s.padding) == 4 { - childWidth -= s.padding[0] + s.padding[2] - childHeight -= s.padding[1] + s.padding[3] - } - } - if s.content != nil { - if c, ok := s.content.(Sizeable); ok { - c.SetSize(childWidth, childHeight) - } - } -} - -func (s *singlePaneLayout) IsFocused() bool { - return s.focused -} - -func (s *singlePaneLayout) GetSize() (int, int) { - return s.width, s.height -} - -func (s *singlePaneLayout) BindingKeys() []key.Binding { - if b, ok := s.content.(Bindings); ok { - return b.BindingKeys() - } - return []key.Binding{} -} - -func (s *singlePaneLayout) Pane() tea.Model { - return s.content -} - -func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout { - layout := &singlePaneLayout{ - content: content, - } - for _, opt := range opts { - opt(layout) - } - return layout -} - -func WithSinglePaneSize(width, height int) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.width = width - opts.height = height - } -} - -func WithSinglePaneFocusable(focusable bool) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.focusable = focusable - } -} - -func WithSinglePaneBordered(bordered bool) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.bordered = bordered - } -} - -func WithSinglePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.borderText = borderText - } -} - -func WithSinglePanePadding(padding ...int) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.padding = padding - } -} - -func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption { - return func(opts *singlePaneLayout) { - opts.activeColor = color - } -} diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index bfb616a53..a41df6ab8 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -11,9 +11,9 @@ type SplitPaneLayout interface { tea.Model Sizeable Bindings - SetLeftPanel(panel Container) - SetRightPanel(panel Container) - SetBottomPanel(panel Container) + SetLeftPanel(panel Container) tea.Cmd + SetRightPanel(panel Container) tea.Cmd + SetBottomPanel(panel Container) tea.Cmd } type splitPaneLayout struct { @@ -53,8 +53,7 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - s.SetSize(msg.Width, msg.Height) - return s, nil + return s, s.SetSize(msg.Width, msg.Height) } if s.rightPanel != nil { @@ -122,7 +121,7 @@ func (s *splitPaneLayout) View() string { return finalView } -func (s *splitPaneLayout) SetSize(width, height int) { +func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { s.width = width s.height = height @@ -147,42 +146,50 @@ func (s *splitPaneLayout) SetSize(width, height int) { rightWidth = width } + var cmds []tea.Cmd if s.leftPanel != nil { - s.leftPanel.SetSize(leftWidth, topHeight) + cmd := s.leftPanel.SetSize(leftWidth, topHeight) + cmds = append(cmds, cmd) } if s.rightPanel != nil { - s.rightPanel.SetSize(rightWidth, topHeight) + cmd := s.rightPanel.SetSize(rightWidth, topHeight) + cmds = append(cmds, cmd) } if s.bottomPanel != nil { - s.bottomPanel.SetSize(width, bottomHeight) + cmd := s.bottomPanel.SetSize(width, bottomHeight) + cmds = append(cmds, cmd) } + return tea.Batch(cmds...) } func (s *splitPaneLayout) GetSize() (int, int) { return s.width, s.height } -func (s *splitPaneLayout) SetLeftPanel(panel Container) { +func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd { s.leftPanel = panel if s.width > 0 && s.height > 0 { - s.SetSize(s.width, s.height) + return s.SetSize(s.width, s.height) } + return nil } -func (s *splitPaneLayout) SetRightPanel(panel Container) { +func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd { s.rightPanel = panel if s.width > 0 && s.height > 0 { - s.SetSize(s.width, s.height) + return s.SetSize(s.width, s.height) } + return nil } -func (s *splitPaneLayout) SetBottomPanel(panel Container) { +func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd { s.bottomPanel = panel if s.width > 0 && s.height > 0 { - s.SetSize(s.width, s.height) + return s.SetSize(s.width, s.height) } + return nil } func (s *splitPaneLayout) BindingKeys() []key.Binding { diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 632e10764..b99dc3dfe 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -54,9 +54,11 @@ func (p *chatPage) Init() tea.Cmd { } func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - p.layout.SetSize(msg.Width, msg.Height) + cmd := p.layout.SetSize(msg.Width, msg.Height) + cmds = append(cmds, cmd) case chat.SendMsg: cmd := p.sendMessage(msg.Text) if cmd != nil { @@ -68,8 +70,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keyMap.NewSession): p.session = session.Session{} - p.clearSidebar() - return p, util.CmdHandler(chat.SessionClearedMsg{}) + return p, tea.Batch( + p.clearSidebar(), + util.CmdHandler(chat.SessionClearedMsg{}), + ) case key.Matches(msg, keyMap.Cancel): if p.session.ID != "" { // Cancel the current session's generation process @@ -80,11 +84,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } u, cmd := p.layout.Update(msg) + cmds = append(cmds, cmd) p.layout = u.(layout.SplitPaneLayout) - if cmd != nil { - return p, cmd - } - return p, nil + return p, tea.Batch(cmds...) } func (p *chatPage) setSidebar() tea.Cmd { @@ -92,16 +94,11 @@ func (p *chatPage) setSidebar() tea.Cmd { chat.NewSidebarCmp(p.session, p.app.History), layout.WithPadding(1, 1, 1, 1), ) - p.layout.SetRightPanel(sidebarContainer) - width, height := p.layout.GetSize() - p.layout.SetSize(width, height) - return sidebarContainer.Init() + return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init()) } -func (p *chatPage) clearSidebar() { - p.layout.SetRightPanel(nil) - width, height := p.layout.GetSize() - p.layout.SetSize(width, height) +func (p *chatPage) clearSidebar() tea.Cmd { + return p.layout.SetRightPanel(nil) } func (p *chatPage) sendMessage(text string) tea.Cmd { @@ -124,8 +121,8 @@ func (p *chatPage) sendMessage(text string) tea.Cmd { return tea.Batch(cmds...) } -func (p *chatPage) SetSize(width, height int) { - p.layout.SetSize(width, height) +func (p *chatPage) SetSize(width, height int) tea.Cmd { + return p.layout.SetSize(width, height) } func (p *chatPage) GetSize() (int, int) { diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go index 0efc69e6e..f0d35fb7b 100644 --- a/internal/tui/page/logs.go +++ b/internal/tui/page/logs.go @@ -23,15 +23,14 @@ type logsPage struct { } func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: p.width = msg.Width p.height = msg.Height - p.table.SetSize(msg.Width, msg.Height/2) - p.details.SetSize(msg.Width, msg.Height/2) + return p, p.SetSize(msg.Width, msg.Height) } - var cmds []tea.Cmd table, cmd := p.table.Update(msg) cmds = append(cmds, cmd) p.table = table.(layout.Container) @@ -60,11 +59,13 @@ func (p *logsPage) GetSize() (int, int) { } // SetSize implements LogPage. -func (p *logsPage) SetSize(width int, height int) { +func (p *logsPage) SetSize(width int, height int) tea.Cmd { p.width = width p.height = height - p.table.SetSize(width, height/2) - p.details.SetSize(width, height/2) + return tea.Batch( + p.table.SetSize(width, height/2), + p.details.SetSize(width, height/2), + ) } func (p *logsPage) Init() tea.Cmd { diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go index bf6cbc105..2fbb34efb 100644 --- a/internal/tui/styles/background.go +++ b/internal/tui/styles/background.go @@ -3,7 +3,6 @@ package styles import ( "fmt" "regexp" - "strconv" "strings" "github.com/charmbracelet/lipgloss" @@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) { return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8) } +// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes +// in `input` with a single 24‑bit background (48;2;R;G;B). func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string { + // Precompute our new-bg sequence once r, g, b := getColorRGB(newBgColor) - newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b) return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { - // Extract content between "\x1b[" and "m" - content := seq[2 : len(seq)-1] - tokens := strings.Split(content, ";") - var newTokens []string - - // Skip background color tokens - for i := 0; i < len(tokens); i++ { - if tokens[i] == "" { - continue - } + const ( + escPrefixLen = 2 // "\x1b[" + escSuffixLen = 1 // "m" + ) + + raw := seq + start := escPrefixLen + end := len(raw) - escSuffixLen - val, err := strconv.Atoi(tokens[i]) - if err != nil { - newTokens = append(newTokens, tokens[i]) - continue + var sb strings.Builder + // reserve enough space: original content minus bg codes + our newBg + sb.Grow((end - start) + len(newBg) + 2) + + // scan from start..end, token by token + for i := start; i < end; { + // find the next ';' or end + j := i + for j < end && raw[j] != ';' { + j++ } + token := raw[i:j] - // Skip background color tokens - if val == 48 { - // Skip "48;5;N" or "48;2;R;G;B" sequences - if i+1 < len(tokens) { - if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil { - switch nextVal { - case 5: - i += 2 // Skip "5" and color index - case 2: - i += 4 // Skip "2" and RGB components + // fast‑path: skip "48;5;N" or "48;2;R;G;B" + if len(token) == 2 && token[0] == '4' && token[1] == '8' { + k := j + 1 + if k < end { + // find next token + l := k + for l < end && raw[l] != ';' { + l++ + } + next := raw[k:l] + if next == "5" { + // skip "48;5;N" + m := l + 1 + for m < end && raw[m] != ';' { + m++ + } + i = m + 1 + continue + } else if next == "2" { + // skip "48;2;R;G;B" + m := l + 1 + for count := 0; count < 3 && m < end; count++ { + for m < end && raw[m] != ';' { + m++ + } + m++ } + i = m + continue } } - } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 { - // Keep non-background tokens - newTokens = append(newTokens, tokens[i]) } - } - // Add new background if provided - if newBg != "" { - newTokens = append(newTokens, strings.Split(newBg, ";")...) + // decide whether to keep this token + // manually parse ASCII digits to int + isNum := true + val := 0 + for p := i; p < j; p++ { + c := raw[p] + if c < '0' || c > '9' { + isNum = false + break + } + val = val*10 + int(c-'0') + } + keep := !isNum || + ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49) + + if keep { + if sb.Len() > 0 { + sb.WriteByte(';') + } + sb.WriteString(token) + } + // advance past this token (and the semicolon) + i = j + 1 } - if len(newTokens) == 0 { - return "" + // append our new background + if sb.Len() > 0 { + sb.WriteByte(';') } + sb.WriteString(newBg) - return "\x1b[" + strings.Join(newTokens, ";") + "m" + return "\x1b[" + sb.String() + "m" }) } diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index aa0df1e31..dd5f4dc51 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -2,19 +2,11 @@ package styles const ( OpenCodeIcon string = "⌬" - SessionsIcon string = "" - ChatIcon string = "" - - BotIcon string = "" - ToolIcon string = "" - UserIcon string = "" CheckIcon string = "✓" - ErrorIcon string = "" - WarningIcon string = "" + ErrorIcon string = "✖" + WarningIcon string = "⚠" InfoIcon string = "" - HintIcon string = "" + HintIcon string = "i" SpinnerIcon string = "..." - BugIcon string = "" - SleepIcon string = "" ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 840ad4905..f3a7298cf 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,6 +1,8 @@ package tui import ( + "context" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -8,6 +10,7 @@ import ( "github.com/kujtimiihoxha/opencode/internal/logging" "github.com/kujtimiihoxha/opencode/internal/permission" "github.com/kujtimiihoxha/opencode/internal/pubsub" + "github.com/kujtimiihoxha/opencode/internal/tui/components/chat" "github.com/kujtimiihoxha/opencode/internal/tui/components/core" "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog" "github.com/kujtimiihoxha/opencode/internal/tui/layout" @@ -16,9 +19,10 @@ import ( ) type keyMap struct { - Logs key.Binding - Quit key.Binding - Help key.Binding + Logs key.Binding + Quit key.Binding + Help key.Binding + SwitchSession key.Binding } var keys = keyMap{ @@ -35,6 +39,10 @@ var keys = keyMap{ key.WithKeys("ctrl+_"), key.WithHelp("ctrl+?", "toggle help"), ), + SwitchSession: key.NewBinding( + key.WithKeys("ctrl+a"), + key.WithHelp("ctrl+a", "switch session"), + ), } var returnKey = key.NewBinding( @@ -64,6 +72,9 @@ type appModel struct { showQuit bool quit dialog.QuitDialog + + showSessionDialog bool + sessionDialog dialog.SessionDialog } func (a appModel) Init() tea.Cmd { @@ -77,6 +88,8 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, cmd) cmd = a.help.Init() cmds = append(cmds, cmd) + cmd = a.sessionDialog.Init() + cmds = append(cmds, cmd) return tea.Batch(cmds...) } @@ -100,6 +113,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.help = help.(dialog.HelpCmp) cmds = append(cmds, helpCmd) + session, sessionCmd := a.sessionDialog.Update(msg) + a.sessionDialog = session.(dialog.SessionDialog) + cmds = append(cmds, sessionCmd) + return a, tea.Batch(cmds...) // Status @@ -144,8 +161,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Permission case pubsub.Event[permission.PermissionRequest]: a.showPermissions = true - a.permissions.SetPermissions(msg.Payload) - return a, nil + return a, a.permissions.SetPermissions(msg.Payload) case dialog.PermissionResponseMsg: switch msg.Action { case dialog.PermissionAllow: @@ -165,6 +181,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showQuit = false return a, nil + case dialog.CloseSessionDialogMsg: + a.showSessionDialog = false + return a, nil + + case chat.SessionSelectedMsg: + a.sessionDialog.SetSelectedSession(msg.ID) + case dialog.SessionSelectedMsg: + a.showSessionDialog = false + if a.currentPage == page.ChatPage { + return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session)) + } + return a, nil + case tea.KeyMsg: switch { case key.Matches(msg, keys.Quit): @@ -172,6 +201,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showHelp { a.showHelp = false } + if a.showSessionDialog { + a.showSessionDialog = false + } + return a, nil + case key.Matches(msg, keys.SwitchSession): + if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions { + // Load sessions and show the dialog + sessions, err := a.app.Sessions.List(context.Background()) + if err != nil { + return a, util.ReportError(err) + } + if len(sessions) == 0 { + return a, util.ReportWarn("No sessions available") + } + a.sessionDialog.SetSessions(sessions) + a.showSessionDialog = true + return a, nil + } return a, nil case key.Matches(msg, logsKeyReturnKey): if a.currentPage == page.LogsPage { @@ -216,6 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if a.showSessionDialog { + d, sessionCmd := a.sessionDialog.Update(msg) + a.sessionDialog = d.(dialog.SessionDialog) + cmds = append(cmds, sessionCmd) + // Only block key messages send all other messages down + if _, ok := msg.(tea.KeyMsg); ok { + return a, tea.Batch(cmds...) + } + } + a.status, _ = a.status.Update(msg) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) @@ -223,18 +280,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { - var cmd tea.Cmd + if a.app.CoderAgent.IsBusy() { + // For now we don't move to any page if the agent is busy + return util.ReportWarn("Agent is busy, please wait...") + } + var cmds []tea.Cmd if _, ok := a.loadedPages[pageID]; !ok { - cmd = a.pages[pageID].Init() + cmd := a.pages[pageID].Init() + cmds = append(cmds, cmd) a.loadedPages[pageID] = true } a.previousPage = a.currentPage a.currentPage = pageID if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok { - sizable.SetSize(a.width, a.height) + cmd := sizable.SetSize(a.width, a.height) + cmds = append(cmds, cmd) } - return cmd + return tea.Batch(cmds...) } func (a appModel) View() string { @@ -304,19 +367,35 @@ func (a appModel) View() string { ) } + if a.showSessionDialog { + overlay := a.sessionDialog.View() + row := lipgloss.Height(appView) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(appView) / 2 + col -= lipgloss.Width(overlay) / 2 + appView = layout.PlaceOverlay( + col, + row, + overlay, + appView, + true, + ) + } + return appView } func New(app *app.App) tea.Model { startPage := page.ChatPage return &appModel{ - currentPage: startPage, - loadedPages: make(map[page.PageID]bool), - status: core.NewStatusCmp(app.LSPClients), - help: dialog.NewHelpCmp(), - quit: dialog.NewQuitCmp(), - permissions: dialog.NewPermissionDialogCmp(), - app: app, + currentPage: startPage, + loadedPages: make(map[page.PageID]bool), + status: core.NewStatusCmp(app.LSPClients), + help: dialog.NewHelpCmp(), + quit: dialog.NewQuitCmp(), + sessionDialog: dialog.NewSessionDialogCmp(), + permissions: dialog.NewPermissionDialogCmp(), + app: app, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), |
