implement patch, update ui, improve rendering

This commit is contained in:
Kujtim Hoxha
2025-04-18 20:17:38 +02:00
parent 05d0e86f10
commit 333ea6ec4b
38 changed files with 3312 additions and 2262 deletions

View File

@@ -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) {

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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, "/")
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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: "",
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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")
}

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 24bit 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
const (
escPrefixLen = 2 // "\x1b["
escSuffixLen = 1 // "m"
)
// Skip background color tokens
for i := 0; i < len(tokens); i++ {
if tokens[i] == "" {
continue
raw := seq
start := escPrefixLen
end := len(raw) - escSuffixLen
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]
val, err := strconv.Atoi(tokens[i])
if err != nil {
newTokens = append(newTokens, tokens[i])
continue
}
// 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
// fastpath: 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])
}
// 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
}
// Add new background if provided
if newBg != "" {
newTokens = append(newTokens, strings.Split(newBg, ";")...)
// append our new background
if sb.Len() > 0 {
sb.WriteByte(';')
}
sb.WriteString(newBg)
if len(newTokens) == 0 {
return ""
}
return "\x1b[" + strings.Join(newTokens, ";") + "m"
return "\x1b[" + sb.String() + "m"
})
}

View File

@@ -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 = "󰒲"
)

View File

@@ -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(),