summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components/chat
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/components/chat')
-rw-r--r--internal/tui/components/chat/chat.go119
-rw-r--r--internal/tui/components/chat/editor.go207
-rw-r--r--internal/tui/components/chat/list.go415
-rw-r--r--internal/tui/components/chat/message.go624
-rw-r--r--internal/tui/components/chat/sidebar.go337
5 files changed, 1702 insertions, 0 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
new file mode 100644
index 000000000..b2b5a5c4a
--- /dev/null
+++ b/internal/tui/components/chat/chat.go
@@ -0,0 +1,119 @@
+package chat
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/version"
+)
+
+type SendMsg struct {
+ Text string
+}
+
+type SessionSelectedMsg = session.Session
+
+type SessionClearedMsg struct{}
+
+type EditorFocusMsg bool
+
+func lspsConfigured(width int) string {
+ cfg := config.Get()
+ title := "LSP Configuration"
+ title = ansi.Truncate(title, width, "…")
+
+ lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
+
+ // Get LSP names and sort them for consistent ordering
+ var lspNames []string
+ for name := range cfg.LSP {
+ lspNames = append(lspNames, name)
+ }
+ sort.Strings(lspNames)
+
+ var lspViews []string
+ for _, name := range lspNames {
+ lsp := cfg.LSP[name]
+ lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
+ fmt.Sprintf("• %s", name),
+ )
+ cmd := lsp.Command
+ cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
+ lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" (%s)", cmd),
+ )
+ lspViews = append(lspViews,
+ styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ lspName,
+ lspPath,
+ ),
+ ),
+ )
+ }
+ return styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lsps,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lspViews...,
+ ),
+ ),
+ )
+}
+
+func logo(width int) string {
+ logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+
+ version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
+
+ return styles.BaseStyle.
+ Bold(true).
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ logo,
+ " ",
+ version,
+ ),
+ )
+}
+
+func repo(width int) string {
+ repo := "https://github.com/kujtimiihoxha/opencode"
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(repo)
+}
+
+func cwd(width int) string {
+ cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(cwd)
+}
+
+func header(width int) string {
+ header := lipgloss.JoinVertical(
+ lipgloss.Top,
+ logo(width),
+ repo(width),
+ "",
+ cwd(width),
+ )
+ return header
+}
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
new file mode 100644
index 000000000..4f6937039
--- /dev/null
+++ b/internal/tui/components/chat/editor.go
@@ -0,0 +1,207 @@
+package chat
+
+import (
+ "os"
+ "os/exec"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "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 editorCmp struct {
+ app *app.App
+ session session.Session
+ textarea textarea.Model
+}
+
+type FocusEditorMsg bool
+
+type focusedEditorKeyMaps struct {
+ Send key.Binding
+ OpenEditor key.Binding
+ Blur key.Binding
+}
+
+type bluredEditorKeyMaps struct {
+ Send key.Binding
+ Focus key.Binding
+ OpenEditor key.Binding
+}
+
+var focusedKeyMaps = focusedEditorKeyMaps{
+ Send: key.NewBinding(
+ key.WithKeys("ctrl+s"),
+ key.WithHelp("ctrl+s", "send message"),
+ ),
+ Blur: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "focus messages"),
+ ),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+}
+
+var bluredKeyMaps = bluredEditorKeyMaps{
+ Send: key.NewBinding(
+ key.WithKeys("ctrl+s", "enter"),
+ key.WithHelp("ctrl+s/enter", "send message"),
+ ),
+ Focus: key.NewBinding(
+ 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 {
+ return textarea.Blink
+}
+
+func (m *editorCmp) send() tea.Cmd {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+ return util.ReportWarn("Agent is working, please wait...")
+ }
+
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ m.textarea.Blur()
+ if value == "" {
+ return nil
+ }
+ return tea.Batch(
+ util.CmdHandler(SendMsg{
+ Text: value,
+ }),
+ )
+}
+
+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ switch msg := msg.(type) {
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ }
+ return m, nil
+ case FocusEditorMsg:
+ if msg {
+ m.textarea.Focus()
+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+ }
+ case tea.KeyMsg:
+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+ return m, util.ReportWarn("Agent is working, please wait...")
+ }
+ 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()
+ }
+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) {
+ return m, m.send()
+ }
+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) {
+ m.textarea.Blur()
+ return m, util.CmdHandler(EditorFocusMsg(false))
+ }
+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) {
+ m.textarea.Focus()
+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+ }
+ }
+ m.textarea, cmd = m.textarea.Update(msg)
+ return m, cmd
+}
+
+func (m *editorCmp) View() string {
+ style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+}
+
+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) {
+ return m.textarea.Width(), m.textarea.Height()
+}
+
+func (m *editorCmp) BindingKeys() []key.Binding {
+ bindings := []key.Binding{}
+ if m.textarea.Focused() {
+ bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
+ } else {
+ bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
+ }
+
+ bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
+ return bindings
+}
+
+func NewEditorCmp(app *app.App) tea.Model {
+ ti := textarea.New()
+ ti.Prompt = " "
+ ti.ShowLineNumbers = false
+ ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
+ ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
+ ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
+ ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+
+ ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
+ ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
+ ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
+ ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+ ti.CharLimit = -1
+ ti.Focus()
+ return &editorCmp{
+ app: app,
+ textarea: ti,
+ }
+}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
new file mode 100644
index 000000000..03a50541e
--- /dev/null
+++ b/internal/tui/components/chat/list.go
@@ -0,0 +1,415 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "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/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 cacheItem struct {
+ width int
+ content []uiMessage
+}
+type messagesCmp struct {
+ app *app.App
+ width, height int
+ writingMode bool
+ viewport viewport.Model
+ session session.Session
+ messages []message.Message
+ uiMessages []uiMessage
+ currentMsgID string
+ cachedContent map[string]cacheItem
+ spinner spinner.Model
+ rendering bool
+}
+type renderFinishedMsg struct{}
+
+func (m *messagesCmp) Init() tea.Cmd {
+ return tea.Batch(m.viewport.Init(), m.spinner.Tick)
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case 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 cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
+ m.uiMessages = append(m.uiMessages, cache.content...)
+ continue
+ }
+ userMsg := renderUserMessage(
+ msg,
+ msg.ID == m.currentMsgID,
+ m.width,
+ pos,
+ )
+ m.uiMessages = append(m.uiMessages, userMsg)
+ m.cachedContent[msg.ID] = cacheItem{
+ width: m.width,
+ content: []uiMessage{userMsg},
+ }
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
+ m.uiMessages = append(m.uiMessages, cache.content...)
+ continue
+ }
+ 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] = cacheItem{
+ width: m.width,
+ content: 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 && v.Finished {
+ return true
+ }
+ }
+ return false
+}
+
+func hasUnfinishedToolCalls(messages []message.Message) bool {
+ toolCalls := make([]message.ToolCall, 0)
+ for _, m := range messages {
+ toolCalls = append(toolCalls, m.ToolCalls()...)
+ }
+ for _, v := range toolCalls {
+ if !v.Finished {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *messagesCmp) working() string {
+ text := ""
+ if m.IsAgentWorking() && len(m.messages) > 0 {
+ task := "Thinking..."
+ lastMessage := m.messages[len(m.messages)-1]
+ if hasToolsWithoutResponse(m.messages) {
+ task = "Waiting for tool response..."
+ } else if hasUnfinishedToolCalls(m.messages) {
+ task = "Building tool call..."
+ } else if !lastMessage.IsFinished() {
+ task = "Generating..."
+ }
+ if task != "" {
+ text += 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
+ for _, msg := range m.messages {
+ delete(m.cachedContent, msg.ID)
+ }
+ m.uiMessages = make([]uiMessage, 0)
+ m.renderView()
+ return nil
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
+ if m.session.ID == session.ID {
+ return nil
+ }
+ m.session = session
+ messages, err := m.app.Messages.List(context.Background(), session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ m.messages = messages
+ m.currentMsgID = m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, m.currentMsgID)
+ m.rendering = true
+ return func() tea.Msg {
+ m.renderView()
+ return renderFinishedMsg{}
+ }
+}
+
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ 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]cacheItem),
+ 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..b8e450079
--- /dev/null
+++ b/internal/tui/components/chat/message.go
@@ -0,0 +1,624 @@
+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 {
+ messages := []uiMessage{}
+ content := msg.Content().String()
+ thinking := msg.IsThinking()
+ thinkingContent := msg.ReasoningContent().Thinking
+ finished := msg.IsFinished()
+ finishData := msg.FinishPart()
+ info := []string{}
+
+ // Add finish info if available
+ if finished {
+ switch finishData.Reason {
+ case message.FinishReasonEndTurn:
+ took := formatTimeDifference(msg.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 != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
+ if content == "" {
+ content = "*Finished without output*"
+ }
+
+ 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
+ } else if thinking && thinkingContent != "" {
+ // Render the thinking content
+ content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
+ }
+
+ for i, toolCall := range msg.ToolCalls() {
+ toolCallContent := renderToolMessage(
+ toolCall,
+ allMessages,
+ messagesService,
+ focusedUIMessageId,
+ false,
+ width,
+ i+1,
+ )
+ messages = append(messages, toolCallContent)
+ position += toolCallContent.height
+ position++ // for the space
+ }
+ return messages
+}
+
+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
+ for _, msg := range futureMessages {
+ for _, result := range msg.ToolResults() {
+ if result.ToolCallID == toolCallID {
+ return &result
+ }
+ }
+ }
+ return nil
+}
+
+func toolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Task"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ case tools.PatchToolName:
+ return "Patch"
+ }
+ return name
+}
+
+func getToolAction(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Preparing prompt..."
+ case tools.BashToolName:
+ return "Building command..."
+ case tools.EditToolName:
+ return "Preparing edit..."
+ case tools.FetchToolName:
+ return "Writing fetch..."
+ case tools.GlobToolName:
+ return "Finding files..."
+ case tools.GrepToolName:
+ return "Searching content..."
+ case tools.LSToolName:
+ return "Listing directory..."
+ case tools.SourcegraphToolName:
+ return "Searching code..."
+ case tools.ViewToolName:
+ return "Reading file..."
+ case tools.WriteToolName:
+ return "Preparing write..."
+ case tools.PatchToolName:
+ return "Preparing patch..."
+ }
+ return "Working..."
+}
+
+// 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.
+ Width(width).
+ 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
+ }
+ style := styles.BaseStyle.
+ Width(width - 1).
+ BorderLeft(true).
+ BorderStyle(lipgloss.ThickBorder()).
+ PaddingLeft(1).
+ BorderForeground(styles.ForgroundDim)
+
+ response := findToolResponse(toolCall.ID, allMessages)
+ toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
+
+ if !toolCall.Finished {
+ // Get a brief description of what the tool is doing
+ toolAction := getToolAction(toolCall.Name)
+
+ // toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ")
+ // truncatedInput := toolInput
+ // if len(truncatedInput) > 10 {
+ // truncatedInput = truncatedInput[len(truncatedInput)-10:]
+ // }
+ //
+ // truncatedInput = styles.BaseStyle.
+ // Italic(true).
+ // Width(width - 2 - lipgloss.Width(toolName)).
+ // Background(styles.BackgroundDim).
+ // Foreground(styles.ForgroundMid).
+ // Render(truncatedInput)
+
+ progressText := styles.BaseStyle.
+ Width(width - 2 - lipgloss.Width(toolName)).
+ Foreground(styles.ForgroundDim).
+ Render(fmt.Sprintf("%s", toolAction))
+
+ content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText))
+ toolMsg := uiMessage{
+ messageType: toolMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return toolMsg
+ }
+ 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...")
+ }
+
+ 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/sidebar.go b/internal/tui/components/chat/sidebar.go
new file mode 100644
index 000000000..d330e592b
--- /dev/null
+++ b/internal/tui/components/chat/sidebar.go
@@ -0,0 +1,337 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/history"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type sidebarCmp struct {
+ width, height int
+ session session.Session
+ history history.Service
+ modFiles map[string]struct {
+ additions int
+ removals int
+ }
+}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+ if m.history != nil {
+ ctx := context.Background()
+ // Subscribe to file events
+ filesCh := m.history.Subscribe(ctx)
+
+ // Initialize the modified files map
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+
+ // Load initial files and calculate diffs
+ m.loadModifiedFiles(ctx)
+
+ // Return a command that will send file events to the Update method
+ return func() tea.Msg {
+ return <-filesCh
+ }
+ }
+ return nil
+}
+
+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 {
+ m.session = msg.Payload
+ }
+ }
+ case pubsub.Event[history.File]:
+ if msg.Payload.SessionID == m.session.ID {
+ // Process the individual file change instead of reloading all files
+ ctx := context.Background()
+ 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
+}
+
+func (m *sidebarCmp) View() string {
+ return styles.BaseStyle.
+ Width(m.width).
+ PaddingLeft(4).
+ PaddingRight(2).
+ Height(m.height - 1).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ " ",
+ m.sessionSection(),
+ " ",
+ lspsConfigured(m.width),
+ " ",
+ m.modifiedFiles(),
+ ),
+ )
+}
+
+func (m *sidebarCmp) sessionSection() string {
+ sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
+ sessionValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(m.width - lipgloss.Width(sessionKey)).
+ Render(fmt.Sprintf(": %s", m.session.Title))
+ return lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ sessionKey,
+ sessionValue,
+ )
+}
+
+func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
+ stats := ""
+ if additions > 0 && removals > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
+ } else if additions > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
+ } else if removals > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
+ }
+ filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ filePathStr,
+ stats,
+ ),
+ )
+}
+
+func (m *sidebarCmp) modifiedFiles() string {
+ modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
+
+ // If no modified files, show a placeholder message
+ if m.modFiles == nil || len(m.modFiles) == 0 {
+ message := "No modified files"
+ remainingWidth := m.width - lipgloss.Width(message)
+ if remainingWidth > 0 {
+ message += strings.Repeat(" ", remainingWidth)
+ }
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
+ ),
+ )
+ }
+
+ // Sort file paths alphabetically for consistent ordering
+ var paths []string
+ for path := range m.modFiles {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+
+ // Create views for each file in sorted order
+ var fileViews []string
+ for _, path := range paths {
+ stats := m.modFiles[path]
+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ fileViews...,
+ ),
+ ),
+ )
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+ m.width = width
+ m.height = height
+ return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
+ return &sidebarCmp{
+ session: session,
+ history: history,
+ }
+}
+
+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
+ if m.history == nil || m.session.ID == "" {
+ return
+ }
+
+ // Get all latest files for this session
+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Get all files for this session (to find initial versions)
+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ 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)
+ if file.Version == history.InitialVersion {
+ continue
+ }
+
+ // Find the initial version for this specific file
+ var initialVersion history.File
+ for _, v := range allFiles {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ continue
+ }
+ if initialVersion.Content == file.Content {
+ continue
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // 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, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
+ }
+ }
+}
+
+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
+ // Skip if this is the initial version (no changes to show)
+ if file.Version == history.InitialVersion {
+ return
+ }
+
+ // Find the initial version for this file
+ initialVersion, err := m.findInitialVersion(ctx, file.Path)
+ if err != nil || initialVersion.ID == "" {
+ return
+ }
+
+ // 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
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ displayPath := getDisplayPath(file.Path)
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ 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, "/")
+}