summaryrefslogtreecommitdiffhomepage
path: root/internal/tui
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
parent05d0e86f10369fd0e51a924ac88029fb92591499 (diff)
downloadopencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.tar.gz
opencode-333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f.zip
implement patch, update ui, improve rendering
Diffstat (limited to 'internal/tui')
-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
-rw-r--r--internal/tui/layout/bento.go392
-rw-r--r--internal/tui/layout/border.go121
-rw-r--r--internal/tui/layout/container.go5
-rw-r--r--internal/tui/layout/grid.go254
-rw-r--r--internal/tui/layout/layout.go6
-rw-r--r--internal/tui/layout/single.go189
-rw-r--r--internal/tui/layout/split.go37
-rw-r--r--internal/tui/page/chat.go31
-rw-r--r--internal/tui/page/logs.go13
-rw-r--r--internal/tui/styles/background.go114
-rw-r--r--internal/tui/styles/icons.go14
-rw-r--r--internal/tui/tui.go111
22 files changed, 1624 insertions, 1864 deletions
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index ded0639bb..537ef392c 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -1,6 +1,9 @@
package chat
import (
+ "os"
+ "os/exec"
+
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
@@ -19,13 +22,15 @@ type editorCmp struct {
}
type focusedEditorKeyMaps struct {
- Send key.Binding
- Blur key.Binding
+ Send key.Binding
+ OpenEditor key.Binding
+ Blur key.Binding
}
type bluredEditorKeyMaps struct {
- Send key.Binding
- Focus key.Binding
+ Send key.Binding
+ Focus key.Binding
+ OpenEditor key.Binding
}
var focusedKeyMaps = focusedEditorKeyMaps{
@@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{
key.WithKeys("esc"),
key.WithHelp("esc", "focus messages"),
),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
}
var bluredKeyMaps = bluredEditorKeyMaps{
@@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{
key.WithKeys("i"),
key.WithHelp("i", "focus editor"),
),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+}
+
+func openEditor() tea.Cmd {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "nvim"
+ }
+
+ tmpfile, err := os.CreateTemp("", "msg_*.md")
+ if err != nil {
+ return util.ReportError(err)
+ }
+ tmpfile.Close()
+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return tea.ExecProcess(c, func(err error) tea.Msg {
+ if err != nil {
+ return util.ReportError(err)
+ }
+ content, err := os.ReadFile(tmpfile.Name())
+ if err != nil {
+ return util.ReportError(err)
+ }
+ os.Remove(tmpfile.Name())
+ return SendMsg{
+ Text: string(content),
+ }
+ })
}
func (m *editorCmp) Init() tea.Cmd {
@@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyMsg:
+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
+ m.textarea.Blur()
+ return m, openEditor()
+ }
// if the key does not match any binding, return
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
return m, m.send()
@@ -108,9 +155,10 @@ func (m *editorCmp) View() string {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
-func (m *editorCmp) SetSize(width, height int) {
+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
+ return nil
}
func (m *editorCmp) GetSize() (int, int) {
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
new file mode 100644
index 000000000..f95b53731
--- /dev/null
+++ b/internal/tui/components/chat/list.go
@@ -0,0 +1,463 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/logging"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+type messagesCmp struct {
+ app *app.App
+ width, height int
+ writingMode bool
+ viewport viewport.Model
+ session session.Session
+ messages []message.Message
+ uiMessages []uiMessage
+ currentMsgID string
+ mutex sync.Mutex
+ cachedContent map[string][]uiMessage
+ spinner spinner.Model
+ rendering bool
+}
+type renderFinishedMsg struct{}
+
+func (m *messagesCmp) Init() tea.Cmd {
+ return tea.Batch(m.viewport.Init())
+}
+
+func (m *messagesCmp) preloadSessions() tea.Cmd {
+ return func() tea.Msg {
+ sessions, err := m.app.Sessions.List(context.Background())
+ if err != nil {
+ return util.ReportError(err)()
+ }
+ if len(sessions) == 0 {
+ return nil
+ }
+ if len(sessions) > 20 {
+ sessions = sessions[:20]
+ }
+ for _, s := range sessions {
+ messages, err := m.app.Messages.List(context.Background(), s.ID)
+ if err != nil {
+ return util.ReportError(err)()
+ }
+ if len(messages) == 0 {
+ continue
+ }
+ m.cacheSessionMessages(messages, m.width)
+
+ }
+ logging.Debug("preloaded sessions")
+
+ return nil
+ }
+}
+
+func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ pos := 0
+ if m.width == 0 {
+ return
+ }
+ for inx, msg := range messages {
+ switch msg.Role {
+ case message.User:
+ userMsg := renderUserMessage(
+ msg,
+ false,
+ width,
+ pos,
+ )
+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ assistantMessages := renderAssistantMessage(
+ msg,
+ inx,
+ messages,
+ m.app.Messages,
+ "",
+ width,
+ pos,
+ )
+ for _, msg := range assistantMessages {
+ pos += msg.height + 1 // + 1 for spacing
+ }
+ m.cachedContent[msg.ID] = assistantMessages
+ }
+ }
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case EditorFocusMsg:
+ m.writingMode = bool(msg)
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ cmd := m.SetSession(msg)
+ return m, cmd
+ }
+ return m, nil
+ case SessionClearedMsg:
+ m.session = session.Session{}
+ m.messages = make([]message.Message, 0)
+ m.currentMsgID = ""
+ m.rendering = false
+ return m, nil
+
+ case renderFinishedMsg:
+ m.rendering = false
+ m.viewport.GotoBottom()
+ case tea.KeyMsg:
+ if m.writingMode {
+ return m, nil
+ }
+ case pubsub.Event[message.Message]:
+ needsRerender := false
+ if msg.Type == pubsub.CreatedEvent {
+ if msg.Payload.SessionID == m.session.ID {
+
+ messageExists := false
+ for _, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ messageExists = true
+ break
+ }
+ }
+
+ if !messageExists {
+ if len(m.messages) > 0 {
+ lastMsgID := m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, lastMsgID)
+ }
+
+ m.messages = append(m.messages, msg.Payload)
+ delete(m.cachedContent, m.currentMsgID)
+ m.currentMsgID = msg.Payload.ID
+ needsRerender = true
+ }
+ }
+ // There are tool calls from the child task
+ for _, v := range m.messages {
+ for _, c := range v.ToolCalls() {
+ if c.ID == msg.Payload.SessionID {
+ delete(m.cachedContent, v.ID)
+ needsRerender = true
+ }
+ }
+ }
+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
+ for i, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ m.messages[i] = msg.Payload
+ delete(m.cachedContent, msg.Payload.ID)
+ needsRerender = true
+ break
+ }
+ }
+ }
+ if needsRerender {
+ m.renderView()
+ if len(m.messages) > 0 {
+ if (msg.Type == pubsub.CreatedEvent) ||
+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
+ m.viewport.GotoBottom()
+ }
+ }
+ }
+ }
+
+ u, cmd := m.viewport.Update(msg)
+ m.viewport = u
+ cmds = append(cmds, cmd)
+
+ spinner, cmd := m.spinner.Update(msg)
+ m.spinner = spinner
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+}
+
+func (m *messagesCmp) IsAgentWorking() bool {
+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
+}
+
+func formatTimeDifference(unixTime1, unixTime2 int64) string {
+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
+
+ if diffSeconds < 60 {
+ return fmt.Sprintf("%.1fs", diffSeconds)
+ }
+
+ minutes := int(diffSeconds / 60)
+ seconds := int(diffSeconds) % 60
+ return fmt.Sprintf("%dm%ds", minutes, seconds)
+}
+
+func (m *messagesCmp) renderView() {
+ m.uiMessages = make([]uiMessage, 0)
+ pos := 0
+
+ if m.width == 0 {
+ return
+ }
+ for inx, msg := range m.messages {
+ switch msg.Role {
+ case message.User:
+ if messages, ok := m.cachedContent[msg.ID]; ok {
+ m.uiMessages = append(m.uiMessages, messages...)
+ continue
+ }
+ userMsg := renderUserMessage(
+ msg,
+ msg.ID == m.currentMsgID,
+ m.width,
+ pos,
+ )
+ m.uiMessages = append(m.uiMessages, userMsg)
+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ if messages, ok := m.cachedContent[msg.ID]; ok {
+ m.uiMessages = append(m.uiMessages, messages...)
+ continue
+ }
+ assistantMessages := renderAssistantMessage(
+ msg,
+ inx,
+ m.messages,
+ m.app.Messages,
+ m.currentMsgID,
+ m.width,
+ pos,
+ )
+ for _, msg := range assistantMessages {
+ m.uiMessages = append(m.uiMessages, msg)
+ pos += msg.height + 1 // + 1 for spacing
+ }
+ m.cachedContent[msg.ID] = assistantMessages
+ }
+ }
+
+ messages := make([]string, 0)
+ for _, v := range m.uiMessages {
+ messages = append(messages, v.content,
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ "",
+ ),
+ )
+ }
+ m.viewport.SetContent(
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ messages...,
+ ),
+ ),
+ )
+}
+
+func (m *messagesCmp) View() string {
+ if m.rendering {
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ "Loading...",
+ m.working(),
+ m.help(),
+ ),
+ )
+ }
+ if len(m.messages) == 0 {
+ content := styles.BaseStyle.
+ Width(m.width).
+ Height(m.height - 1).
+ Render(
+ m.initialScreen(),
+ )
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ content,
+ "",
+ m.help(),
+ ),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ m.viewport.View(),
+ m.working(),
+ m.help(),
+ ),
+ )
+}
+
+func hasToolsWithoutResponse(messages []message.Message) bool {
+ toolCalls := make([]message.ToolCall, 0)
+ toolResults := make([]message.ToolResult, 0)
+ for _, m := range messages {
+ toolCalls = append(toolCalls, m.ToolCalls()...)
+ toolResults = append(toolResults, m.ToolResults()...)
+ }
+
+ for _, v := range toolCalls {
+ found := false
+ for _, r := range toolResults {
+ if v.ID == r.ToolCallID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *messagesCmp) working() string {
+ text := ""
+ if m.IsAgentWorking() {
+ task := "Thinking..."
+ lastMessage := m.messages[len(m.messages)-1]
+ if hasToolsWithoutResponse(m.messages) {
+ task = "Waiting for tool response..."
+ } else if !lastMessage.IsFinished() {
+ lastUpdate := lastMessage.UpdatedAt
+ currentTime := time.Now().Unix()
+ if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
+ task = "Building tool call..."
+ } else if lastMessage.Content().String() == "" {
+ task = "Generating..."
+ }
+ task = ""
+ }
+ if task != "" {
+ text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
+ fmt.Sprintf("%s %s ", m.spinner.View(), task),
+ )
+ }
+ }
+ return text
+}
+
+func (m *messagesCmp) help() string {
+ text := ""
+
+ if m.writingMode {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
+ )
+ } else {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(text)
+}
+
+func (m *messagesCmp) initialScreen() string {
+ return styles.BaseStyle.Width(m.width).Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ "",
+ lspsConfigured(m.width),
+ ),
+ )
+}
+
+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+ if m.width == width && m.height == height {
+ return nil
+ }
+ m.width = width
+ m.height = height
+ m.viewport.Width = width
+ m.viewport.Height = height - 2
+ m.renderView()
+ return m.preloadSessions()
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
+ if m.session.ID == session.ID {
+ return nil
+ }
+ m.rendering = true
+ return func() tea.Msg {
+ m.session = session
+ messages, err := m.app.Messages.List(context.Background(), session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ m.messages = messages
+ m.currentMsgID = m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, m.currentMsgID)
+ m.renderView()
+ return renderFinishedMsg{}
+ }
+}
+
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
+ return bindings
+}
+
+func NewMessagesCmp(app *app.App) tea.Model {
+ s := spinner.New()
+ s.Spinner = spinner.Pulse
+ return &messagesCmp{
+ app: app,
+ writingMode: true,
+ cachedContent: make(map[string][]uiMessage),
+ viewport: viewport.New(0, 0),
+ spinner: s,
+ }
+}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
new file mode 100644
index 000000000..be6c7ce50
--- /dev/null
+++ b/internal/tui/components/chat/message.go
@@ -0,0 +1,561 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type uiMessageType int
+
+const (
+ userMessageType uiMessageType = iota
+ assistantMessageType
+ toolMessageType
+
+ maxResultHeight = 15
+)
+
+var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
+
+type uiMessage struct {
+ ID string
+ messageType uiMessageType
+ position int
+ height int
+ content string
+}
+
+type renderCache struct {
+ mutex sync.Mutex
+ cache map[string][]uiMessage
+}
+
+func toMarkdown(content string, focused bool, width int) string {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(false)),
+ glamour.WithWordWrap(width),
+ )
+ if focused {
+ r, _ = glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(width),
+ )
+ }
+ rendered, _ := r.Render(content)
+ return rendered
+}
+
+func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
+ style := styles.BaseStyle.
+ Width(width - 1).
+ BorderLeft(true).
+ Foreground(styles.ForgroundDim).
+ BorderForeground(styles.PrimaryColor).
+ BorderStyle(lipgloss.ThickBorder())
+ if isUser {
+ style = style.
+ BorderForeground(styles.Blue)
+ }
+ parts := []string{
+ styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
+ }
+
+ // remove newline at the end
+ parts[0] = strings.TrimSuffix(parts[0], "\n")
+ if len(info) > 0 {
+ parts = append(parts, info...)
+ }
+ rendered := style.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ ),
+ )
+
+ return rendered
+}
+
+func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
+ content := renderMessage(msg.Content().String(), true, isFocused, width)
+ userMsg := uiMessage{
+ ID: msg.ID,
+ messageType: userMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return userMsg
+}
+
+// Returns multiple uiMessages because of the tool calls
+func renderAssistantMessage(
+ msg message.Message,
+ msgIndex int,
+ allMessages []message.Message, // we need this to get tool results and the user message
+ messagesService message.Service, // We need this to get the task tool messages
+ focusedUIMessageId string,
+ width int,
+ position int,
+) []uiMessage {
+ // find the user message that is before this assistant message
+ var userMsg message.Message
+ for i := msgIndex - 1; i >= 0; i-- {
+ msg := allMessages[i]
+ if msg.Role == message.User {
+ userMsg = allMessages[i]
+ break
+ }
+ }
+
+ messages := []uiMessage{}
+ content := msg.Content().String()
+ finished := msg.IsFinished()
+ finishData := msg.FinishPart()
+ info := []string{}
+
+ // Add finish info if available
+ if finished {
+ switch finishData.Reason {
+ case message.FinishReasonEndTurn:
+ took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
+ ))
+ case message.FinishReasonCanceled:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
+ ))
+ case message.FinishReasonError:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
+ ))
+ case message.FinishReasonPermissionDenied:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
+ ))
+ }
+ }
+ if content != "" {
+ content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
+ messages = append(messages, uiMessage{
+ ID: msg.ID,
+ messageType: assistantMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ })
+ position += messages[0].height
+ position++ // for the space
+ }
+
+ for i, toolCall := range msg.ToolCalls() {
+ toolCallContent := renderToolMessage(
+ toolCall,
+ allMessages,
+ messagesService,
+ focusedUIMessageId,
+ false,
+ width,
+ i+1,
+ )
+ messages = append(messages, toolCallContent)
+ position += toolCallContent.height
+ position++ // for the space
+ }
+ return messages
+}
+
+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
+ for _, msg := range futureMessages {
+ for _, result := range msg.ToolResults() {
+ if result.ToolCallID == toolCallID {
+ return &result
+ }
+ }
+ }
+ return nil
+}
+
+func toolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Task"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ }
+ return name
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if len(mainParam) > paramsWidth {
+ mainParam = mainParam[:paramsWidth-3] + "..."
+ }
+
+ if len(params) == 1 {
+ return mainParam
+ }
+ otherParams := params[1:]
+ // create pairs of key/value
+ // if odd number of params, the last one is a key without value
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
+ if remainingWidth < 30 {
+ // No space for the params, just show the main
+ return mainParam
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ if strings.HasPrefix(path, wd) {
+ path = strings.TrimPrefix(path, wd)
+ }
+ if strings.HasPrefix(path, "/") {
+ path = strings.TrimPrefix(path, "/")
+ }
+ if strings.HasPrefix(path, "./") {
+ path = strings.TrimPrefix(path, "./")
+ }
+ if strings.HasPrefix(path, "../") {
+ path = strings.TrimPrefix(path, "../")
+ }
+ return path
+}
+
+func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
+ params := ""
+ switch toolCall.Name {
+ case agent.AgentToolName:
+ var params agent.AgentParams
+ json.Unmarshal([]byte(toolCall.Input), &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 {
diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go
deleted file mode 100644
index c47c4e090..000000000
--- a/internal/tui/layout/bento.go
+++ /dev/null
@@ -1,392 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type paneID string
-
-const (
- BentoLeftPane paneID = "left"
- BentoRightTopPane paneID = "right-top"
- BentoRightBottomPane paneID = "right-bottom"
-)
-
-type BentoPanes map[paneID]tea.Model
-
-const (
- defaultLeftWidthRatio = 0.2
- defaultRightTopHeightRatio = 0.85
-
- minLeftWidth = 10
- minRightBottomHeight = 10
-)
-
-type BentoLayout interface {
- tea.Model
- Sizeable
- Bindings
-}
-
-type BentoKeyBindings struct {
- SwitchPane key.Binding
- SwitchPaneBack key.Binding
- HideCurrentPane key.Binding
- ShowAllPanes key.Binding
-}
-
-var defaultBentoKeyBindings = BentoKeyBindings{
- SwitchPane: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch pane"),
- ),
- SwitchPaneBack: key.NewBinding(
- key.WithKeys("shift+tab"),
- key.WithHelp("shift+tab", "switch pane back"),
- ),
- HideCurrentPane: key.NewBinding(
- key.WithKeys("X"),
- key.WithHelp("X", "hide current pane"),
- ),
- ShowAllPanes: key.NewBinding(
- key.WithKeys("R"),
- key.WithHelp("R", "show all panes"),
- ),
-}
-
-type bentoLayout struct {
- width int
- height int
-
- leftWidthRatio float64
- rightTopHeightRatio float64
-
- currentPane paneID
- panes map[paneID]SinglePaneLayout
- hiddenPanes map[paneID]bool
-}
-
-func (b *bentoLayout) GetSize() (int, int) {
- return b.width, b.height
-}
-
-func (b *bentoLayout) Init() tea.Cmd {
- var cmds []tea.Cmd
- for _, pane := range b.panes {
- cmd := pane.Init()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return tea.Batch(cmds...)
-}
-
-func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- b.SetSize(msg.Width, msg.Height)
- return b, nil
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
- return b, b.SwitchPane(false)
- case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
- return b, b.SwitchPane(true)
- case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
- return b, b.HidePane(b.currentPane)
- case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
- for id := range b.hiddenPanes {
- delete(b.hiddenPanes, id)
- }
- b.SetSize(b.width, b.height)
- return b, nil
- }
- }
-
- var cmds []tea.Cmd
- for id, pane := range b.panes {
- u, cmd := pane.Update(msg)
- b.panes[id] = u.(SinglePaneLayout)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return b, tea.Batch(cmds...)
-}
-
-func (b *bentoLayout) View() string {
- if b.width <= 0 || b.height <= 0 {
- return ""
- }
-
- for id, pane := range b.panes {
- if b.currentPane == id {
- pane.Focus()
- } else {
- pane.Blur()
- }
- }
-
- leftVisible := false
- rightTopVisible := false
- rightBottomVisible := false
-
- var leftPane, rightTopPane, rightBottomPane string
-
- if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- leftPane = pane.View()
- leftVisible = true
- }
-
- if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- rightTopPane = pane.View()
- rightTopVisible = true
- }
-
- if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- rightBottomPane = pane.View()
- rightBottomVisible = true
- }
-
- if leftVisible {
- if rightTopVisible || rightBottomVisible {
- rightSection := ""
- if rightTopVisible && rightBottomVisible {
- rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
- } else if rightTopVisible {
- rightSection = rightTopPane
- } else {
- rightSection = rightBottomPane
- }
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
- lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
- )
- } else {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
- }
- } else if rightTopVisible || rightBottomVisible {
- if rightTopVisible && rightBottomVisible {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
- lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
- )
- } else if rightTopVisible {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
- } else {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
- }
- }
- return ""
-}
-
-func (b *bentoLayout) SetSize(width int, height int) {
- if width < 0 || height < 0 {
- return
- }
- b.width = width
- b.height = height
-
- leftExists := false
- rightTopExists := false
- rightBottomExists := false
-
- if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- leftExists = true
- }
- if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- rightTopExists = true
- }
- if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- rightBottomExists = true
- }
-
- leftWidth := 0
- rightWidth := 0
- rightTopHeight := 0
- rightBottomHeight := 0
-
- if leftExists && (rightTopExists || rightBottomExists) {
- leftWidth = int(float64(width) * b.leftWidthRatio)
- if leftWidth < minLeftWidth && width >= minLeftWidth {
- leftWidth = minLeftWidth
- }
- rightWidth = width - leftWidth
-
- if rightTopExists && rightBottomExists {
- rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
- rightBottomHeight = height - rightTopHeight
-
- if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
- rightBottomHeight = minRightBottomHeight
- rightTopHeight = height - rightBottomHeight
- }
- } else if rightTopExists {
- rightTopHeight = height
- } else if rightBottomExists {
- rightBottomHeight = height
- }
- } else if leftExists {
- leftWidth = width
- } else if rightTopExists || rightBottomExists {
- rightWidth = width
-
- if rightTopExists && rightBottomExists {
- rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
- rightBottomHeight = height - rightTopHeight
-
- if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
- rightBottomHeight = minRightBottomHeight
- rightTopHeight = height - rightBottomHeight
- }
- } else if rightTopExists {
- rightTopHeight = height
- } else if rightBottomExists {
- rightBottomHeight = height
- }
- }
-
- if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- pane.SetSize(leftWidth, height)
- }
- if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- pane.SetSize(rightWidth, rightTopHeight)
- }
- if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- pane.SetSize(rightWidth, rightBottomHeight)
- }
-}
-
-func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
- if len(b.panes)-len(b.hiddenPanes) == 1 {
- return nil
- }
- if _, ok := b.panes[pane]; ok {
- b.hiddenPanes[pane] = true
- }
- b.SetSize(b.width, b.height)
- return b.SwitchPane(false)
-}
-
-func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
- orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
- orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
-
- order := orderForward
- if back {
- order = orderBackward
- }
-
- currentIdx := -1
- for i, id := range order {
- if id == b.currentPane {
- currentIdx = i
- break
- }
- }
-
- if currentIdx == -1 {
- for _, id := range order {
- if _, exists := b.panes[id]; exists {
- if _, hidden := b.hiddenPanes[id]; !hidden {
- b.currentPane = id
- break
- }
- }
- }
- } else {
- startIdx := currentIdx
- for {
- currentIdx = (currentIdx + 1) % len(order)
-
- nextID := order[currentIdx]
- if _, exists := b.panes[nextID]; exists {
- if _, hidden := b.hiddenPanes[nextID]; !hidden {
- b.currentPane = nextID
- break
- }
- }
-
- if currentIdx == startIdx {
- break
- }
- }
- }
-
- var cmds []tea.Cmd
- for id, pane := range b.panes {
- if _, ok := b.hiddenPanes[id]; ok {
- continue
- }
- if id == b.currentPane {
- cmds = append(cmds, pane.Focus())
- } else {
- cmds = append(cmds, pane.Blur())
- }
- }
-
- return tea.Batch(cmds...)
-}
-
-func (s *bentoLayout) BindingKeys() []key.Binding {
- bindings := KeyMapToSlice(defaultBentoKeyBindings)
- if b, ok := s.panes[s.currentPane].(Bindings); ok {
- bindings = append(bindings, b.BindingKeys()...)
- }
- return bindings
-}
-
-type BentoLayoutOption func(*bentoLayout)
-
-func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
- p := make(map[paneID]SinglePaneLayout, len(panes))
- for id, pane := range panes {
- if sp, ok := pane.(SinglePaneLayout); !ok {
- p[id] = NewSinglePane(
- pane,
- WithSinglePaneFocusable(true),
- WithSinglePaneBordered(true),
- )
- } else {
- p[id] = sp
- }
- }
- if len(p) == 0 {
- panic("no panes provided for BentoLayout")
- }
- layout := &bentoLayout{
- panes: p,
- hiddenPanes: make(map[paneID]bool),
- currentPane: BentoLeftPane,
- leftWidthRatio: defaultLeftWidthRatio,
- rightTopHeightRatio: defaultRightTopHeightRatio,
- }
-
- for _, opt := range opts {
- opt(layout)
- }
-
- return layout
-}
-
-func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
- return func(b *bentoLayout) {
- if ratio > 0 && ratio < 1 {
- b.leftWidthRatio = ratio
- }
- }
-}
-
-func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
- return func(b *bentoLayout) {
- if ratio > 0 && ratio < 1 {
- b.rightTopHeightRatio = ratio
- }
- }
-}
-
-func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
- return func(b *bentoLayout) {
- b.currentPane = pane
- }
-}
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
deleted file mode 100644
index ea9f5e0bc..000000000
--- a/internal/tui/layout/border.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package layout
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/opencode/internal/tui/styles"
-)
-
-type BorderPosition int
-
-const (
- TopLeftBorder BorderPosition = iota
- TopMiddleBorder
- TopRightBorder
- BottomLeftBorder
- BottomMiddleBorder
- BottomRightBorder
-)
-
-var (
- ActiveBorder = styles.Blue
- InactivePreviewBorder = styles.Grey
-)
-
-type BorderOptions struct {
- Active bool
- EmbeddedText map[BorderPosition]string
- ActiveColor lipgloss.TerminalColor
- InactiveColor lipgloss.TerminalColor
- ActiveBorder lipgloss.Border
- InactiveBorder lipgloss.Border
-}
-
-func Borderize(content string, opts BorderOptions) string {
- if opts.EmbeddedText == nil {
- opts.EmbeddedText = make(map[BorderPosition]string)
- }
- if opts.ActiveColor == nil {
- opts.ActiveColor = ActiveBorder
- }
- if opts.InactiveColor == nil {
- opts.InactiveColor = InactivePreviewBorder
- }
- if opts.ActiveBorder == (lipgloss.Border{}) {
- opts.ActiveBorder = lipgloss.ThickBorder()
- }
- if opts.InactiveBorder == (lipgloss.Border{}) {
- opts.InactiveBorder = lipgloss.NormalBorder()
- }
-
- var (
- thickness = map[bool]lipgloss.Border{
- true: opts.ActiveBorder,
- false: opts.InactiveBorder,
- }
- color = map[bool]lipgloss.TerminalColor{
- true: opts.ActiveColor,
- false: opts.InactiveColor,
- }
- border = thickness[opts.Active]
- style = lipgloss.NewStyle().Foreground(color[opts.Active])
- width = lipgloss.Width(content)
- )
-
- encloseInSquareBrackets := func(text string) string {
- if text != "" {
- return fmt.Sprintf("%s%s%s",
- style.Render(border.TopRight),
- text,
- style.Render(border.TopLeft),
- )
- }
- return text
- }
- buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
- leftText = encloseInSquareBrackets(leftText)
- middleText = encloseInSquareBrackets(middleText)
- rightText = encloseInSquareBrackets(rightText)
- // Calculate length of border between embedded texts
- remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
- leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
- rightBorderLen := max(0, remaining-leftBorderLen)
- // Then construct border string
- s := leftText +
- style.Render(strings.Repeat(inbetween, leftBorderLen)) +
- middleText +
- style.Render(strings.Repeat(inbetween, rightBorderLen)) +
- rightText
- // Make it fit in the space available between the two corners.
- s = lipgloss.NewStyle().
- Inline(true).
- MaxWidth(width).
- Render(s)
- // Add the corners
- return style.Render(leftCorner) + s + style.Render(rightCorner)
- }
- // Stack top border, content and horizontal borders, and bottom border.
- return strings.Join([]string{
- buildHorizontalBorder(
- opts.EmbeddedText[TopLeftBorder],
- opts.EmbeddedText[TopMiddleBorder],
- opts.EmbeddedText[TopRightBorder],
- border.TopLeft,
- border.Top,
- border.TopRight,
- ),
- lipgloss.NewStyle().
- BorderForeground(color[opts.Active]).
- Border(border, false, true, false, true).Render(content),
- buildHorizontalBorder(
- opts.EmbeddedText[BottomLeftBorder],
- opts.EmbeddedText[BottomMiddleBorder],
- opts.EmbeddedText[BottomRightBorder],
- border.BottomLeft,
- border.Bottom,
- border.BottomRight,
- ),
- }, "\n")
-}
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index c86d954ea..fdb9ab403 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -86,7 +86,7 @@ func (c *container) View() string {
return style.Render(c.content.View())
}
-func (c *container) SetSize(width, height int) {
+func (c *container) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
@@ -113,8 +113,9 @@ func (c *container) SetSize(width, height int) {
// Set content size with adjusted dimensions
contentWidth := max(0, width-horizontalSpace)
contentHeight := max(0, height-verticalSpace)
- sizeable.SetSize(contentWidth, contentHeight)
+ return sizeable.SetSize(contentWidth, contentHeight)
}
+ return nil
}
func (c *container) GetSize() (int, int) {
diff --git a/internal/tui/layout/grid.go b/internal/tui/layout/grid.go
deleted file mode 100644
index 6be493e2c..000000000
--- a/internal/tui/layout/grid.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type GridLayout interface {
- tea.Model
- Sizeable
- Bindings
- Panes() [][]tea.Model
-}
-
-type gridLayout struct {
- width int
- height int
-
- rows int
- columns int
-
- panes [][]tea.Model
-
- gap int
- bordered bool
- focusable bool
-
- currentRow int
- currentColumn int
-
- activeColor lipgloss.TerminalColor
-}
-
-type GridOption func(*gridLayout)
-
-func (g *gridLayout) Init() tea.Cmd {
- var cmds []tea.Cmd
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- cmds = append(cmds, g.panes[i][j].Init())
- }
- }
- }
- return tea.Batch(cmds...)
-}
-
-func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- g.SetSize(msg.Width, msg.Height)
- return g, nil
- case tea.KeyMsg:
- if key.Matches(msg, g.nextPaneBinding()) {
- return g.focusNextPane()
- }
- }
-
- // Update all panes
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- var cmd tea.Cmd
- g.panes[i][j], cmd = g.panes[i][j].Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- }
-
- return g, tea.Batch(cmds...)
-}
-
-func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
- if !g.focusable {
- return g, nil
- }
-
- var cmds []tea.Cmd
-
- // Blur current pane
- if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
- if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
- cmds = append(cmds, currentPane.Blur())
- }
- }
-
- // Find next valid pane
- g.currentColumn++
- if g.currentColumn >= len(g.panes[g.currentRow]) {
- g.currentColumn = 0
- g.currentRow++
- if g.currentRow >= len(g.panes) {
- g.currentRow = 0
- }
- }
-
- // Focus next pane
- if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
- if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
- cmds = append(cmds, nextPane.Focus())
- }
- }
-
- return g, tea.Batch(cmds...)
-}
-
-func (g *gridLayout) nextPaneBinding() key.Binding {
- return key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "next pane"),
- )
-}
-
-func (g *gridLayout) View() string {
- if len(g.panes) == 0 {
- return ""
- }
-
- // Calculate dimensions for each cell
- cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
- cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
-
- // Render each row
- rows := make([]string, g.rows)
- for i := range g.rows {
- // Render each column in this row
- cols := make([]string, len(g.panes[i]))
- for j := range g.panes[i] {
- if g.panes[i][j] == nil {
- cols[j] = ""
- continue
- }
-
- // Set size for each pane
- if sizable, ok := g.panes[i][j].(Sizeable); ok {
- effectiveWidth, effectiveHeight := cellWidth, cellHeight
- if g.bordered {
- effectiveWidth -= 2
- effectiveHeight -= 2
- }
- sizable.SetSize(effectiveWidth, effectiveHeight)
- }
-
- // Render the pane
- content := g.panes[i][j].View()
-
- // Apply border if needed
- if g.bordered {
- isFocused := false
- if focusable, ok := g.panes[i][j].(Focusable); ok {
- isFocused = focusable.IsFocused()
- }
-
- borderText := map[BorderPosition]string{}
- if bordered, ok := g.panes[i][j].(Bordered); ok {
- borderText = bordered.BorderText()
- }
-
- content = Borderize(content, BorderOptions{
- Active: isFocused,
- EmbeddedText: borderText,
- })
- }
-
- cols[j] = content
- }
-
- // Join columns with gap
- rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
- }
-
- // Join rows with gap
- return lipgloss.JoinVertical(lipgloss.Left, rows...)
-}
-
-func (g *gridLayout) SetSize(width, height int) {
- g.width = width
- g.height = height
-}
-
-func (g *gridLayout) GetSize() (int, int) {
- return g.width, g.height
-}
-
-func (g *gridLayout) BindingKeys() []key.Binding {
- var bindings []key.Binding
- bindings = append(bindings, g.nextPaneBinding())
-
- // Collect bindings from all panes
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- if bindable, ok := g.panes[i][j].(Bindings); ok {
- bindings = append(bindings, bindable.BindingKeys()...)
- }
- }
- }
- }
-
- return bindings
-}
-
-func (g *gridLayout) Panes() [][]tea.Model {
- return g.panes
-}
-
-// NewGridLayout creates a new grid layout with the given number of rows and columns
-func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
- grid := &gridLayout{
- rows: rows,
- columns: cols,
- panes: panes,
- gap: 1,
- }
-
- for _, opt := range opts {
- opt(grid)
- }
-
- return grid
-}
-
-// WithGridGap sets the gap between cells
-func WithGridGap(gap int) GridOption {
- return func(g *gridLayout) {
- g.gap = gap
- }
-}
-
-// WithGridBordered sets whether cells should have borders
-func WithGridBordered(bordered bool) GridOption {
- return func(g *gridLayout) {
- g.bordered = bordered
- }
-}
-
-// WithGridFocusable sets whether the grid supports focus navigation
-func WithGridFocusable(focusable bool) GridOption {
- return func(g *gridLayout) {
- g.focusable = focusable
- }
-}
-
-// WithGridActiveColor sets the active border color
-func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
- return func(g *gridLayout) {
- g.activeColor = color
- }
-}
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
index 2f17c4a0e..495a3fbc5 100644
--- a/internal/tui/layout/layout.go
+++ b/internal/tui/layout/layout.go
@@ -13,12 +13,8 @@ type Focusable interface {
IsFocused() bool
}
-type Bordered interface {
- BorderText() map[BorderPosition]string
-}
-
type Sizeable interface {
- SetSize(width, height int)
+ SetSize(width, height int) tea.Cmd
GetSize() (int, int)
}
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
deleted file mode 100644
index c77fa0d78..000000000
--- a/internal/tui/layout/single.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type SinglePaneLayout interface {
- tea.Model
- Focusable
- Sizeable
- Bindings
- Pane() tea.Model
-}
-
-type singlePaneLayout struct {
- width int
- height int
-
- focusable bool
- focused bool
-
- bordered bool
- borderText map[BorderPosition]string
-
- content tea.Model
-
- padding []int
-
- activeColor lipgloss.TerminalColor
-}
-
-type SinglePaneOption func(*singlePaneLayout)
-
-func (s *singlePaneLayout) Init() tea.Cmd {
- return s.content.Init()
-}
-
-func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.SetSize(msg.Width, msg.Height)
- return s, nil
- }
- u, cmd := s.content.Update(msg)
- s.content = u
- return s, cmd
-}
-
-func (s *singlePaneLayout) View() string {
- style := lipgloss.NewStyle().Width(s.width).Height(s.height)
- if s.bordered {
- style = style.Width(s.width - 2).Height(s.height - 2)
- }
- if s.padding != nil {
- style = style.Padding(s.padding...)
- }
- content := style.Render(s.content.View())
- if s.bordered {
- if s.borderText == nil {
- s.borderText = map[BorderPosition]string{}
- }
- if bordered, ok := s.content.(Bordered); ok {
- s.borderText = bordered.BorderText()
- }
- return Borderize(content, BorderOptions{
- Active: s.focused,
- EmbeddedText: s.borderText,
- })
- }
- return content
-}
-
-func (s *singlePaneLayout) Blur() tea.Cmd {
- if s.focusable {
- s.focused = false
- }
- if blurable, ok := s.content.(Focusable); ok {
- return blurable.Blur()
- }
- return nil
-}
-
-func (s *singlePaneLayout) Focus() tea.Cmd {
- if s.focusable {
- s.focused = true
- }
- if focusable, ok := s.content.(Focusable); ok {
- return focusable.Focus()
- }
- return nil
-}
-
-func (s *singlePaneLayout) SetSize(width, height int) {
- s.width = width
- s.height = height
- childWidth, childHeight := s.width, s.height
- if s.bordered {
- childWidth -= 2
- childHeight -= 2
- }
- if s.padding != nil {
- if len(s.padding) == 1 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[0] * 2
- } else if len(s.padding) == 2 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[1] * 2
- } else if len(s.padding) == 3 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[1] + s.padding[2]
- } else if len(s.padding) == 4 {
- childWidth -= s.padding[0] + s.padding[2]
- childHeight -= s.padding[1] + s.padding[3]
- }
- }
- if s.content != nil {
- if c, ok := s.content.(Sizeable); ok {
- c.SetSize(childWidth, childHeight)
- }
- }
-}
-
-func (s *singlePaneLayout) IsFocused() bool {
- return s.focused
-}
-
-func (s *singlePaneLayout) GetSize() (int, int) {
- return s.width, s.height
-}
-
-func (s *singlePaneLayout) BindingKeys() []key.Binding {
- if b, ok := s.content.(Bindings); ok {
- return b.BindingKeys()
- }
- return []key.Binding{}
-}
-
-func (s *singlePaneLayout) Pane() tea.Model {
- return s.content
-}
-
-func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
- layout := &singlePaneLayout{
- content: content,
- }
- for _, opt := range opts {
- opt(layout)
- }
- return layout
-}
-
-func WithSinglePaneSize(width, height int) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.width = width
- opts.height = height
- }
-}
-
-func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.focusable = focusable
- }
-}
-
-func WithSinglePaneBordered(bordered bool) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.bordered = bordered
- }
-}
-
-func WithSinglePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.borderText = borderText
- }
-}
-
-func WithSinglePanePadding(padding ...int) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.padding = padding
- }
-}
-
-func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.activeColor = color
- }
-}
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index bfb616a53..a41df6ab8 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -11,9 +11,9 @@ type SplitPaneLayout interface {
tea.Model
Sizeable
Bindings
- SetLeftPanel(panel Container)
- SetRightPanel(panel Container)
- SetBottomPanel(panel Container)
+ SetLeftPanel(panel Container) tea.Cmd
+ SetRightPanel(panel Container) tea.Cmd
+ SetBottomPanel(panel Container) tea.Cmd
}
type splitPaneLayout struct {
@@ -53,8 +53,7 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- s.SetSize(msg.Width, msg.Height)
- return s, nil
+ return s, s.SetSize(msg.Width, msg.Height)
}
if s.rightPanel != nil {
@@ -122,7 +121,7 @@ func (s *splitPaneLayout) View() string {
return finalView
}
-func (s *splitPaneLayout) SetSize(width, height int) {
+func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
s.width = width
s.height = height
@@ -147,42 +146,50 @@ func (s *splitPaneLayout) SetSize(width, height int) {
rightWidth = width
}
+ var cmds []tea.Cmd
if s.leftPanel != nil {
- s.leftPanel.SetSize(leftWidth, topHeight)
+ cmd := s.leftPanel.SetSize(leftWidth, topHeight)
+ cmds = append(cmds, cmd)
}
if s.rightPanel != nil {
- s.rightPanel.SetSize(rightWidth, topHeight)
+ cmd := s.rightPanel.SetSize(rightWidth, topHeight)
+ cmds = append(cmds, cmd)
}
if s.bottomPanel != nil {
- s.bottomPanel.SetSize(width, bottomHeight)
+ cmd := s.bottomPanel.SetSize(width, bottomHeight)
+ cmds = append(cmds, cmd)
}
+ return tea.Batch(cmds...)
}
func (s *splitPaneLayout) GetSize() (int, int) {
return s.width, s.height
}
-func (s *splitPaneLayout) SetLeftPanel(panel Container) {
+func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
s.leftPanel = panel
if s.width > 0 && s.height > 0 {
- s.SetSize(s.width, s.height)
+ return s.SetSize(s.width, s.height)
}
+ return nil
}
-func (s *splitPaneLayout) SetRightPanel(panel Container) {
+func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
s.rightPanel = panel
if s.width > 0 && s.height > 0 {
- s.SetSize(s.width, s.height)
+ return s.SetSize(s.width, s.height)
}
+ return nil
}
-func (s *splitPaneLayout) SetBottomPanel(panel Container) {
+func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
s.bottomPanel = panel
if s.width > 0 && s.height > 0 {
- s.SetSize(s.width, s.height)
+ return s.SetSize(s.width, s.height)
}
+ return nil
}
func (s *splitPaneLayout) BindingKeys() []key.Binding {
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 632e10764..b99dc3dfe 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -54,9 +54,11 @@ func (p *chatPage) Init() tea.Cmd {
}
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- p.layout.SetSize(msg.Width, msg.Height)
+ cmd := p.layout.SetSize(msg.Width, msg.Height)
+ cmds = append(cmds, cmd)
case chat.SendMsg:
cmd := p.sendMessage(msg.Text)
if cmd != nil {
@@ -68,8 +70,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keyMap.NewSession):
p.session = session.Session{}
- p.clearSidebar()
- return p, util.CmdHandler(chat.SessionClearedMsg{})
+ return p, tea.Batch(
+ p.clearSidebar(),
+ util.CmdHandler(chat.SessionClearedMsg{}),
+ )
case key.Matches(msg, keyMap.Cancel):
if p.session.ID != "" {
// Cancel the current session's generation process
@@ -80,11 +84,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
u, cmd := p.layout.Update(msg)
+ cmds = append(cmds, cmd)
p.layout = u.(layout.SplitPaneLayout)
- if cmd != nil {
- return p, cmd
- }
- return p, nil
+ return p, tea.Batch(cmds...)
}
func (p *chatPage) setSidebar() tea.Cmd {
@@ -92,16 +94,11 @@ func (p *chatPage) setSidebar() tea.Cmd {
chat.NewSidebarCmp(p.session, p.app.History),
layout.WithPadding(1, 1, 1, 1),
)
- p.layout.SetRightPanel(sidebarContainer)
- width, height := p.layout.GetSize()
- p.layout.SetSize(width, height)
- return sidebarContainer.Init()
+ return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
}
-func (p *chatPage) clearSidebar() {
- p.layout.SetRightPanel(nil)
- width, height := p.layout.GetSize()
- p.layout.SetSize(width, height)
+func (p *chatPage) clearSidebar() tea.Cmd {
+ return p.layout.SetRightPanel(nil)
}
func (p *chatPage) sendMessage(text string) tea.Cmd {
@@ -124,8 +121,8 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
return tea.Batch(cmds...)
}
-func (p *chatPage) SetSize(width, height int) {
- p.layout.SetSize(width, height)
+func (p *chatPage) SetSize(width, height int) tea.Cmd {
+ return p.layout.SetSize(width, height)
}
func (p *chatPage) GetSize() (int, int) {
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
index 0efc69e6e..f0d35fb7b 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -23,15 +23,14 @@ type logsPage struct {
}
func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width = msg.Width
p.height = msg.Height
- p.table.SetSize(msg.Width, msg.Height/2)
- p.details.SetSize(msg.Width, msg.Height/2)
+ return p, p.SetSize(msg.Width, msg.Height)
}
- var cmds []tea.Cmd
table, cmd := p.table.Update(msg)
cmds = append(cmds, cmd)
p.table = table.(layout.Container)
@@ -60,11 +59,13 @@ func (p *logsPage) GetSize() (int, int) {
}
// SetSize implements LogPage.
-func (p *logsPage) SetSize(width int, height int) {
+func (p *logsPage) SetSize(width int, height int) tea.Cmd {
p.width = width
p.height = height
- p.table.SetSize(width, height/2)
- p.details.SetSize(width, height/2)
+ return tea.Batch(
+ p.table.SetSize(width, height/2),
+ p.details.SetSize(width, height/2),
+ )
}
func (p *logsPage) Init() tea.Cmd {
diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go
index bf6cbc105..2fbb34efb 100644
--- a/internal/tui/styles/background.go
+++ b/internal/tui/styles/background.go
@@ -3,7 +3,6 @@ package styles
import (
"fmt"
"regexp"
- "strconv"
"strings"
"github.com/charmbracelet/lipgloss"
@@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
}
+// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
+// in `input` with a single 24‑bit background (48;2;R;G;B).
func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
+ // Precompute our new-bg sequence once
r, g, b := getColorRGB(newBgColor)
-
newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
- // Extract content between "\x1b[" and "m"
- content := seq[2 : len(seq)-1]
- tokens := strings.Split(content, ";")
- var newTokens []string
-
- // Skip background color tokens
- for i := 0; i < len(tokens); i++ {
- if tokens[i] == "" {
- continue
- }
+ const (
+ escPrefixLen = 2 // "\x1b["
+ escSuffixLen = 1 // "m"
+ )
+
+ raw := seq
+ start := escPrefixLen
+ end := len(raw) - escSuffixLen
- val, err := strconv.Atoi(tokens[i])
- if err != nil {
- newTokens = append(newTokens, tokens[i])
- continue
+ var sb strings.Builder
+ // reserve enough space: original content minus bg codes + our newBg
+ sb.Grow((end - start) + len(newBg) + 2)
+
+ // scan from start..end, token by token
+ for i := start; i < end; {
+ // find the next ';' or end
+ j := i
+ for j < end && raw[j] != ';' {
+ j++
}
+ token := raw[i:j]
- // Skip background color tokens
- if val == 48 {
- // Skip "48;5;N" or "48;2;R;G;B" sequences
- if i+1 < len(tokens) {
- if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
- switch nextVal {
- case 5:
- i += 2 // Skip "5" and color index
- case 2:
- i += 4 // Skip "2" and RGB components
+ // fast‑path: skip "48;5;N" or "48;2;R;G;B"
+ if len(token) == 2 && token[0] == '4' && token[1] == '8' {
+ k := j + 1
+ if k < end {
+ // find next token
+ l := k
+ for l < end && raw[l] != ';' {
+ l++
+ }
+ next := raw[k:l]
+ if next == "5" {
+ // skip "48;5;N"
+ m := l + 1
+ for m < end && raw[m] != ';' {
+ m++
+ }
+ i = m + 1
+ continue
+ } else if next == "2" {
+ // skip "48;2;R;G;B"
+ m := l + 1
+ for count := 0; count < 3 && m < end; count++ {
+ for m < end && raw[m] != ';' {
+ m++
+ }
+ m++
}
+ i = m
+ continue
}
}
- } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
- // Keep non-background tokens
- newTokens = append(newTokens, tokens[i])
}
- }
- // Add new background if provided
- if newBg != "" {
- newTokens = append(newTokens, strings.Split(newBg, ";")...)
+ // decide whether to keep this token
+ // manually parse ASCII digits to int
+ isNum := true
+ val := 0
+ for p := i; p < j; p++ {
+ c := raw[p]
+ if c < '0' || c > '9' {
+ isNum = false
+ break
+ }
+ val = val*10 + int(c-'0')
+ }
+ keep := !isNum ||
+ ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49)
+
+ if keep {
+ if sb.Len() > 0 {
+ sb.WriteByte(';')
+ }
+ sb.WriteString(token)
+ }
+ // advance past this token (and the semicolon)
+ i = j + 1
}
- if len(newTokens) == 0 {
- return ""
+ // append our new background
+ if sb.Len() > 0 {
+ sb.WriteByte(';')
}
+ sb.WriteString(newBg)
- return "\x1b[" + strings.Join(newTokens, ";") + "m"
+ return "\x1b[" + sb.String() + "m"
})
}
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index aa0df1e31..dd5f4dc51 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -2,19 +2,11 @@ package styles
const (
OpenCodeIcon string = "⌬"
- SessionsIcon string = "󰧑"
- ChatIcon string = "󰭹"
-
- BotIcon string = "󰚩"
- ToolIcon string = ""
- UserIcon string = ""
CheckIcon string = "✓"
- ErrorIcon string = ""
- WarningIcon string = ""
+ ErrorIcon string = "✖"
+ WarningIcon string = "⚠"
InfoIcon string = ""
- HintIcon string = ""
+ HintIcon string = "i"
SpinnerIcon string = "..."
- BugIcon string = ""
- SleepIcon string = "󰒲"
)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 840ad4905..f3a7298cf 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -1,6 +1,8 @@
package tui
import (
+ "context"
+
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -8,6 +10,7 @@ import (
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
"github.com/kujtimiihoxha/opencode/internal/tui/components/core"
"github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
@@ -16,9 +19,10 @@ import (
)
type keyMap struct {
- Logs key.Binding
- Quit key.Binding
- Help key.Binding
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ SwitchSession key.Binding
}
var keys = keyMap{
@@ -35,6 +39,10 @@ var keys = keyMap{
key.WithKeys("ctrl+_"),
key.WithHelp("ctrl+?", "toggle help"),
),
+ SwitchSession: key.NewBinding(
+ key.WithKeys("ctrl+a"),
+ key.WithHelp("ctrl+a", "switch session"),
+ ),
}
var returnKey = key.NewBinding(
@@ -64,6 +72,9 @@ type appModel struct {
showQuit bool
quit dialog.QuitDialog
+
+ showSessionDialog bool
+ sessionDialog dialog.SessionDialog
}
func (a appModel) Init() tea.Cmd {
@@ -77,6 +88,8 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, cmd)
cmd = a.help.Init()
cmds = append(cmds, cmd)
+ cmd = a.sessionDialog.Init()
+ cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
@@ -100,6 +113,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.help = help.(dialog.HelpCmp)
cmds = append(cmds, helpCmd)
+ session, sessionCmd := a.sessionDialog.Update(msg)
+ a.sessionDialog = session.(dialog.SessionDialog)
+ cmds = append(cmds, sessionCmd)
+
return a, tea.Batch(cmds...)
// Status
@@ -144,8 +161,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Permission
case pubsub.Event[permission.PermissionRequest]:
a.showPermissions = true
- a.permissions.SetPermissions(msg.Payload)
- return a, nil
+ return a, a.permissions.SetPermissions(msg.Payload)
case dialog.PermissionResponseMsg:
switch msg.Action {
case dialog.PermissionAllow:
@@ -165,6 +181,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showQuit = false
return a, nil
+ case dialog.CloseSessionDialogMsg:
+ a.showSessionDialog = false
+ return a, nil
+
+ case chat.SessionSelectedMsg:
+ a.sessionDialog.SetSelectedSession(msg.ID)
+ case dialog.SessionSelectedMsg:
+ a.showSessionDialog = false
+ if a.currentPage == page.ChatPage {
+ return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
+ }
+ return a, nil
+
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
@@ -172,6 +201,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showHelp {
a.showHelp = false
}
+ if a.showSessionDialog {
+ a.showSessionDialog = false
+ }
+ return a, nil
+ case key.Matches(msg, keys.SwitchSession):
+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
+ // Load sessions and show the dialog
+ sessions, err := a.app.Sessions.List(context.Background())
+ if err != nil {
+ return a, util.ReportError(err)
+ }
+ if len(sessions) == 0 {
+ return a, util.ReportWarn("No sessions available")
+ }
+ a.sessionDialog.SetSessions(sessions)
+ a.showSessionDialog = true
+ return a, nil
+ }
return a, nil
case key.Matches(msg, logsKeyReturnKey):
if a.currentPage == page.LogsPage {
@@ -216,6 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+ if a.showSessionDialog {
+ d, sessionCmd := a.sessionDialog.Update(msg)
+ a.sessionDialog = d.(dialog.SessionDialog)
+ cmds = append(cmds, sessionCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
+
a.status, _ = a.status.Update(msg)
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
cmds = append(cmds, cmd)
@@ -223,18 +280,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
- var cmd tea.Cmd
+ if a.app.CoderAgent.IsBusy() {
+ // For now we don't move to any page if the agent is busy
+ return util.ReportWarn("Agent is busy, please wait...")
+ }
+ var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
- cmd = a.pages[pageID].Init()
+ cmd := a.pages[pageID].Init()
+ cmds = append(cmds, cmd)
a.loadedPages[pageID] = true
}
a.previousPage = a.currentPage
a.currentPage = pageID
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
- sizable.SetSize(a.width, a.height)
+ cmd := sizable.SetSize(a.width, a.height)
+ cmds = append(cmds, cmd)
}
- return cmd
+ return tea.Batch(cmds...)
}
func (a appModel) View() string {
@@ -304,19 +367,35 @@ func (a appModel) View() string {
)
}
+ if a.showSessionDialog {
+ overlay := a.sessionDialog.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
+
return appView
}
func New(app *app.App) tea.Model {
startPage := page.ChatPage
return &appModel{
- currentPage: startPage,
- loadedPages: make(map[page.PageID]bool),
- status: core.NewStatusCmp(app.LSPClients),
- help: dialog.NewHelpCmp(),
- quit: dialog.NewQuitCmp(),
- permissions: dialog.NewPermissionDialogCmp(),
- app: app,
+ currentPage: startPage,
+ loadedPages: make(map[page.PageID]bool),
+ status: core.NewStatusCmp(app.LSPClients),
+ help: dialog.NewHelpCmp(),
+ quit: dialog.NewQuitCmp(),
+ sessionDialog: dialog.NewSessionDialogCmp(),
+ permissions: dialog.NewPermissionDialogCmp(),
+ app: app,
pages: map[page.PageID]tea.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),