summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-18 20:17:38 +0200
committerKujtim Hoxha <[email protected]>2025-04-21 13:42:27 +0200
commit333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f (patch)
treee0d456417368e8716c81ee43b82be3d6ed39c59e /internal/tui/components
parent05d0e86f10369fd0e51a924ac88029fb92591499 (diff)
downloadopencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.tar.gz
opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.zip
implement patch, update ui, improve rendering
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/chat/editor.go58
-rw-r--r--internal/tui/components/chat/list.go463
-rw-r--r--internal/tui/components/chat/message.go561
-rw-r--r--internal/tui/components/chat/messages.go742
-rw-r--r--internal/tui/components/chat/sidebar.go94
-rw-r--r--internal/tui/components/core/status.go24
-rw-r--r--internal/tui/components/dialog/permission.go16
-rw-r--r--internal/tui/components/dialog/session.go224
-rw-r--r--internal/tui/components/logs/details.go16
-rw-r--r--internal/tui/components/logs/table.go3
10 files changed, 1401 insertions, 800 deletions
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index ded0639bb..537ef392c 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -1,6 +1,9 @@
package chat
import (
+ "os"
+ "os/exec"
+
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
@@ -19,13 +22,15 @@ type editorCmp struct {
}
type focusedEditorKeyMaps struct {
- Send key.Binding
- Blur key.Binding
+ Send key.Binding
+ OpenEditor key.Binding
+ Blur key.Binding
}
type bluredEditorKeyMaps struct {
- Send key.Binding
- Focus key.Binding
+ Send key.Binding
+ Focus key.Binding
+ OpenEditor key.Binding
}
var focusedKeyMaps = focusedEditorKeyMaps{
@@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{
key.WithKeys("esc"),
key.WithHelp("esc", "focus messages"),
),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
}
var bluredKeyMaps = bluredEditorKeyMaps{
@@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{
key.WithKeys("i"),
key.WithHelp("i", "focus editor"),
),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+}
+
+func openEditor() tea.Cmd {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "nvim"
+ }
+
+ tmpfile, err := os.CreateTemp("", "msg_*.md")
+ if err != nil {
+ return util.ReportError(err)
+ }
+ tmpfile.Close()
+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return tea.ExecProcess(c, func(err error) tea.Msg {
+ if err != nil {
+ return util.ReportError(err)
+ }
+ content, err := os.ReadFile(tmpfile.Name())
+ if err != nil {
+ return util.ReportError(err)
+ }
+ os.Remove(tmpfile.Name())
+ return SendMsg{
+ Text: string(content),
+ }
+ })
}
func (m *editorCmp) Init() tea.Cmd {
@@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyMsg:
+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
+ m.textarea.Blur()
+ return m, openEditor()
+ }
// if the key does not match any binding, return
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
return m, m.send()
@@ -108,9 +155,10 @@ func (m *editorCmp) View() string {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
-func (m *editorCmp) SetSize(width, height int) {
+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
+ return nil
}
func (m *editorCmp) GetSize() (int, int) {
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
new file mode 100644
index 000000000..f95b53731
--- /dev/null
+++ b/internal/tui/components/chat/list.go
@@ -0,0 +1,463 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/logging"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+type messagesCmp struct {
+ app *app.App
+ width, height int
+ writingMode bool
+ viewport viewport.Model
+ session session.Session
+ messages []message.Message
+ uiMessages []uiMessage
+ currentMsgID string
+ mutex sync.Mutex
+ cachedContent map[string][]uiMessage
+ spinner spinner.Model
+ rendering bool
+}
+type renderFinishedMsg struct{}
+
+func (m *messagesCmp) Init() tea.Cmd {
+ return tea.Batch(m.viewport.Init())
+}
+
+func (m *messagesCmp) preloadSessions() tea.Cmd {
+ return func() tea.Msg {
+ sessions, err := m.app.Sessions.List(context.Background())
+ if err != nil {
+ return util.ReportError(err)()
+ }
+ if len(sessions) == 0 {
+ return nil
+ }
+ if len(sessions) > 20 {
+ sessions = sessions[:20]
+ }
+ for _, s := range sessions {
+ messages, err := m.app.Messages.List(context.Background(), s.ID)
+ if err != nil {
+ return util.ReportError(err)()
+ }
+ if len(messages) == 0 {
+ continue
+ }
+ m.cacheSessionMessages(messages, m.width)
+
+ }
+ logging.Debug("preloaded sessions")
+
+ return nil
+ }
+}
+
+func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ pos := 0
+ if m.width == 0 {
+ return
+ }
+ for inx, msg := range messages {
+ switch msg.Role {
+ case message.User:
+ userMsg := renderUserMessage(
+ msg,
+ false,
+ width,
+ pos,
+ )
+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ assistantMessages := renderAssistantMessage(
+ msg,
+ inx,
+ messages,
+ m.app.Messages,
+ "",
+ width,
+ pos,
+ )
+ for _, msg := range assistantMessages {
+ pos += msg.height + 1 // + 1 for spacing
+ }
+ m.cachedContent[msg.ID] = assistantMessages
+ }
+ }
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case EditorFocusMsg:
+ m.writingMode = bool(msg)
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ cmd := m.SetSession(msg)
+ return m, cmd
+ }
+ return m, nil
+ case SessionClearedMsg:
+ m.session = session.Session{}
+ m.messages = make([]message.Message, 0)
+ m.currentMsgID = ""
+ m.rendering = false
+ return m, nil
+
+ case renderFinishedMsg:
+ m.rendering = false
+ m.viewport.GotoBottom()
+ case tea.KeyMsg:
+ if m.writingMode {
+ return m, nil
+ }
+ case pubsub.Event[message.Message]:
+ needsRerender := false
+ if msg.Type == pubsub.CreatedEvent {
+ if msg.Payload.SessionID == m.session.ID {
+
+ messageExists := false
+ for _, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ messageExists = true
+ break
+ }
+ }
+
+ if !messageExists {
+ if len(m.messages) > 0 {
+ lastMsgID := m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, lastMsgID)
+ }
+
+ m.messages = append(m.messages, msg.Payload)
+ delete(m.cachedContent, m.currentMsgID)
+ m.currentMsgID = msg.Payload.ID
+ needsRerender = true
+ }
+ }
+ // There are tool calls from the child task
+ for _, v := range m.messages {
+ for _, c := range v.ToolCalls() {
+ if c.ID == msg.Payload.SessionID {
+ delete(m.cachedContent, v.ID)
+ needsRerender = true
+ }
+ }
+ }
+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
+ for i, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ m.messages[i] = msg.Payload
+ delete(m.cachedContent, msg.Payload.ID)
+ needsRerender = true
+ break
+ }
+ }
+ }
+ if needsRerender {
+ m.renderView()
+ if len(m.messages) > 0 {
+ if (msg.Type == pubsub.CreatedEvent) ||
+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
+ m.viewport.GotoBottom()
+ }
+ }
+ }
+ }
+
+ u, cmd := m.viewport.Update(msg)
+ m.viewport = u
+ cmds = append(cmds, cmd)
+
+ spinner, cmd := m.spinner.Update(msg)
+ m.spinner = spinner
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+}
+
+func (m *messagesCmp) IsAgentWorking() bool {
+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
+}
+
+func formatTimeDifference(unixTime1, unixTime2 int64) string {
+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
+
+ if diffSeconds < 60 {
+ return fmt.Sprintf("%.1fs", diffSeconds)
+ }
+
+ minutes := int(diffSeconds / 60)
+ seconds := int(diffSeconds) % 60
+ return fmt.Sprintf("%dm%ds", minutes, seconds)
+}
+
+func (m *messagesCmp) renderView() {
+ m.uiMessages = make([]uiMessage, 0)
+ pos := 0
+
+ if m.width == 0 {
+ return
+ }
+ for inx, msg := range m.messages {
+ switch msg.Role {
+ case message.User:
+ if messages, ok := m.cachedContent[msg.ID]; ok {
+ m.uiMessages = append(m.uiMessages, messages...)
+ continue
+ }
+ userMsg := renderUserMessage(
+ msg,
+ msg.ID == m.currentMsgID,
+ m.width,
+ pos,
+ )
+ m.uiMessages = append(m.uiMessages, userMsg)
+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ if messages, ok := m.cachedContent[msg.ID]; ok {
+ m.uiMessages = append(m.uiMessages, messages...)
+ continue
+ }
+ assistantMessages := renderAssistantMessage(
+ msg,
+ inx,
+ m.messages,
+ m.app.Messages,
+ m.currentMsgID,
+ m.width,
+ pos,
+ )
+ for _, msg := range assistantMessages {
+ m.uiMessages = append(m.uiMessages, msg)
+ pos += msg.height + 1 // + 1 for spacing
+ }
+ m.cachedContent[msg.ID] = assistantMessages
+ }
+ }
+
+ messages := make([]string, 0)
+ for _, v := range m.uiMessages {
+ messages = append(messages, v.content,
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ "",
+ ),
+ )
+ }
+ m.viewport.SetContent(
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ messages...,
+ ),
+ ),
+ )
+}
+
+func (m *messagesCmp) View() string {
+ if m.rendering {
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ "Loading...",
+ m.working(),
+ m.help(),
+ ),
+ )
+ }
+ if len(m.messages) == 0 {
+ content := styles.BaseStyle.
+ Width(m.width).
+ Height(m.height - 1).
+ Render(
+ m.initialScreen(),
+ )
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ content,
+ "",
+ m.help(),
+ ),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ m.viewport.View(),
+ m.working(),
+ m.help(),
+ ),
+ )
+}
+
+func hasToolsWithoutResponse(messages []message.Message) bool {
+ toolCalls := make([]message.ToolCall, 0)
+ toolResults := make([]message.ToolResult, 0)
+ for _, m := range messages {
+ toolCalls = append(toolCalls, m.ToolCalls()...)
+ toolResults = append(toolResults, m.ToolResults()...)
+ }
+
+ for _, v := range toolCalls {
+ found := false
+ for _, r := range toolResults {
+ if v.ID == r.ToolCallID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *messagesCmp) working() string {
+ text := ""
+ if m.IsAgentWorking() {
+ task := "Thinking..."
+ lastMessage := m.messages[len(m.messages)-1]
+ if hasToolsWithoutResponse(m.messages) {
+ task = "Waiting for tool response..."
+ } else if !lastMessage.IsFinished() {
+ lastUpdate := lastMessage.UpdatedAt
+ currentTime := time.Now().Unix()
+ if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
+ task = "Building tool call..."
+ } else if lastMessage.Content().String() == "" {
+ task = "Generating..."
+ }
+ task = ""
+ }
+ if task != "" {
+ text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
+ fmt.Sprintf("%s %s ", m.spinner.View(), task),
+ )
+ }
+ }
+ return text
+}
+
+func (m *messagesCmp) help() string {
+ text := ""
+
+ if m.writingMode {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
+ )
+ } else {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(text)
+}
+
+func (m *messagesCmp) initialScreen() string {
+ return styles.BaseStyle.Width(m.width).Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ "",
+ lspsConfigured(m.width),
+ ),
+ )
+}
+
+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+ if m.width == width && m.height == height {
+ return nil
+ }
+ m.width = width
+ m.height = height
+ m.viewport.Width = width
+ m.viewport.Height = height - 2
+ m.renderView()
+ return m.preloadSessions()
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
+ if m.session.ID == session.ID {
+ return nil
+ }
+ m.rendering = true
+ return func() tea.Msg {
+ m.session = session
+ messages, err := m.app.Messages.List(context.Background(), session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ m.messages = messages
+ m.currentMsgID = m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, m.currentMsgID)
+ m.renderView()
+ return renderFinishedMsg{}
+ }
+}
+
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
+ return bindings
+}
+
+func NewMessagesCmp(app *app.App) tea.Model {
+ s := spinner.New()
+ s.Spinner = spinner.Pulse
+ return &messagesCmp{
+ app: app,
+ writingMode: true,
+ cachedContent: make(map[string][]uiMessage),
+ viewport: viewport.New(0, 0),
+ spinner: s,
+ }
+}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
new file mode 100644
index 000000000..be6c7ce50
--- /dev/null
+++ b/internal/tui/components/chat/message.go
@@ -0,0 +1,561 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type uiMessageType int
+
+const (
+ userMessageType uiMessageType = iota
+ assistantMessageType
+ toolMessageType
+
+ maxResultHeight = 15
+)
+
+var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
+
+type uiMessage struct {
+ ID string
+ messageType uiMessageType
+ position int
+ height int
+ content string
+}
+
+type renderCache struct {
+ mutex sync.Mutex
+ cache map[string][]uiMessage
+}
+
+func toMarkdown(content string, focused bool, width int) string {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(false)),
+ glamour.WithWordWrap(width),
+ )
+ if focused {
+ r, _ = glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(width),
+ )
+ }
+ rendered, _ := r.Render(content)
+ return rendered
+}
+
+func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
+ style := styles.BaseStyle.
+ Width(width - 1).
+ BorderLeft(true).
+ Foreground(styles.ForgroundDim).
+ BorderForeground(styles.PrimaryColor).
+ BorderStyle(lipgloss.ThickBorder())
+ if isUser {
+ style = style.
+ BorderForeground(styles.Blue)
+ }
+ parts := []string{
+ styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
+ }
+
+ // remove newline at the end
+ parts[0] = strings.TrimSuffix(parts[0], "\n")
+ if len(info) > 0 {
+ parts = append(parts, info...)
+ }
+ rendered := style.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ ),
+ )
+
+ return rendered
+}
+
+func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
+ content := renderMessage(msg.Content().String(), true, isFocused, width)
+ userMsg := uiMessage{
+ ID: msg.ID,
+ messageType: userMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return userMsg
+}
+
+// Returns multiple uiMessages because of the tool calls
+func renderAssistantMessage(
+ msg message.Message,
+ msgIndex int,
+ allMessages []message.Message, // we need this to get tool results and the user message
+ messagesService message.Service, // We need this to get the task tool messages
+ focusedUIMessageId string,
+ width int,
+ position int,
+) []uiMessage {
+ // find the user message that is before this assistant message
+ var userMsg message.Message
+ for i := msgIndex - 1; i >= 0; i-- {
+ msg := allMessages[i]
+ if msg.Role == message.User {
+ userMsg = allMessages[i]
+ break
+ }
+ }
+
+ messages := []uiMessage{}
+ content := msg.Content().String()
+ finished := msg.IsFinished()
+ finishData := msg.FinishPart()
+ info := []string{}
+
+ // Add finish info if available
+ if finished {
+ switch finishData.Reason {
+ case message.FinishReasonEndTurn:
+ took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
+ ))
+ case message.FinishReasonCanceled:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
+ ))
+ case message.FinishReasonError:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
+ ))
+ case message.FinishReasonPermissionDenied:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
+ ))
+ }
+ }
+ if content != "" {
+ content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
+ messages = append(messages, uiMessage{
+ ID: msg.ID,
+ messageType: assistantMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ })
+ position += messages[0].height
+ position++ // for the space
+ }
+
+ for i, toolCall := range msg.ToolCalls() {
+ toolCallContent := renderToolMessage(
+ toolCall,
+ allMessages,
+ messagesService,
+ focusedUIMessageId,
+ false,
+ width,
+ i+1,
+ )
+ messages = append(messages, toolCallContent)
+ position += toolCallContent.height
+ position++ // for the space
+ }
+ return messages
+}
+
+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
+ for _, msg := range futureMessages {
+ for _, result := range msg.ToolResults() {
+ if result.ToolCallID == toolCallID {
+ return &result
+ }
+ }
+ }
+ return nil
+}
+
+func toolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Task"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ }
+ return name
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if len(mainParam) > paramsWidth {
+ mainParam = mainParam[:paramsWidth-3] + "..."
+ }
+
+ if len(params) == 1 {
+ return mainParam
+ }
+ otherParams := params[1:]
+ // create pairs of key/value
+ // if odd number of params, the last one is a key without value
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
+ if remainingWidth < 30 {
+ // No space for the params, just show the main
+ return mainParam
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ if strings.HasPrefix(path, wd) {
+ path = strings.TrimPrefix(path, wd)
+ }
+ if strings.HasPrefix(path, "/") {
+ path = strings.TrimPrefix(path, "/")
+ }
+ if strings.HasPrefix(path, "./") {
+ path = strings.TrimPrefix(path, "./")
+ }
+ if strings.HasPrefix(path, "../") {
+ path = strings.TrimPrefix(path, "../")
+ }
+ return path
+}
+
+func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
+ params := ""
+ switch toolCall.Name {
+ case agent.AgentToolName:
+ var params agent.AgentParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
+ return renderParams(paramWidth, prompt)
+ case tools.BashToolName:
+ var params tools.BashParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ command := strings.ReplaceAll(params.Command, "\n", " ")
+ return renderParams(paramWidth, command)
+ case tools.EditToolName:
+ var params tools.EditParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ return renderParams(paramWidth, filePath)
+ case tools.FetchToolName:
+ var params tools.FetchParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ 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), &params)
+ 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), &params)
+ 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), &params)
+ path := params.Path
+ if path == "" {
+ path = "."
+ }
+ return renderParams(paramWidth, path)
+ case tools.SourcegraphToolName:
+ var params tools.SourcegraphParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ return renderParams(paramWidth, params.Query)
+ case tools.ViewToolName:
+ var params tools.ViewParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ 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), &params)
+ 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), &params)
+ 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), &params)
+ 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- 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), &params)
- value = params.FilePath
- case tools.WriteToolName:
- key = "Write"
- var params tools.WriteParams
- json.Unmarshal([]byte(toolCall.Input), &params)
- 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), &params)
- 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 {