mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-30 22:24:19 +01:00
implement patch, update ui, improve rendering
This commit is contained in:
@@ -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) {
|
||||
|
||||
463
internal/tui/components/chat/list.go
Normal file
463
internal/tui/components/chat/list.go
Normal 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,
|
||||
}
|
||||
}
|
||||
561
internal/tui/components/chat/message.go
Normal file
561
internal/tui/components/chat/message.go
Normal 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), ¶ms)
|
||||
prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
|
||||
return renderParams(paramWidth, prompt)
|
||||
case tools.BashToolName:
|
||||
var params tools.BashParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
command := strings.ReplaceAll(params.Command, "\n", " ")
|
||||
return renderParams(paramWidth, command)
|
||||
case tools.EditToolName:
|
||||
var params tools.EditParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
case tools.FetchToolName:
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
url := params.URL
|
||||
toolParams := []string{
|
||||
url,
|
||||
}
|
||||
if params.Format != "" {
|
||||
toolParams = append(toolParams, "format", params.Format)
|
||||
}
|
||||
if params.Timeout != 0 {
|
||||
toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.GlobToolName:
|
||||
var params tools.GlobParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
pattern := params.Pattern
|
||||
toolParams := []string{
|
||||
pattern,
|
||||
}
|
||||
if params.Path != "" {
|
||||
toolParams = append(toolParams, "path", params.Path)
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.GrepToolName:
|
||||
var params tools.GrepParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
pattern := params.Pattern
|
||||
toolParams := []string{
|
||||
pattern,
|
||||
}
|
||||
if params.Path != "" {
|
||||
toolParams = append(toolParams, "path", params.Path)
|
||||
}
|
||||
if params.Include != "" {
|
||||
toolParams = append(toolParams, "include", params.Include)
|
||||
}
|
||||
if params.LiteralText {
|
||||
toolParams = append(toolParams, "literal", "true")
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.LSToolName:
|
||||
var params tools.LSParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
path := params.Path
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
return renderParams(paramWidth, path)
|
||||
case tools.SourcegraphToolName:
|
||||
var params tools.SourcegraphParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
return renderParams(paramWidth, params.Query)
|
||||
case tools.ViewToolName:
|
||||
var params tools.ViewParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
toolParams := []string{
|
||||
filePath,
|
||||
}
|
||||
if params.Limit != 0 {
|
||||
toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
|
||||
}
|
||||
if params.Offset != 0 {
|
||||
toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.WriteToolName:
|
||||
var params tools.WriteParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
default:
|
||||
input := strings.ReplaceAll(toolCall.Input, "\n", " ")
|
||||
params = renderParams(paramWidth, input)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func truncateHeight(content string, height int) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > height {
|
||||
return strings.Join(lines[:height], "\n")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
|
||||
if response.IsError {
|
||||
errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
|
||||
errContent = ansi.Truncate(errContent, width-1, "...")
|
||||
return styles.BaseStyle.
|
||||
Foreground(styles.Error).
|
||||
Render(errContent)
|
||||
}
|
||||
resultContent := truncateHeight(response.Content, maxResultHeight)
|
||||
switch toolCall.Name {
|
||||
case agent.AgentToolName:
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, false, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.BashToolName:
|
||||
resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.EditToolName:
|
||||
metadata := tools.EditResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
|
||||
formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
|
||||
return formattedDiff
|
||||
case tools.FetchToolName:
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
mdFormat := "markdown"
|
||||
switch params.Format {
|
||||
case "text":
|
||||
mdFormat = "text"
|
||||
case "html":
|
||||
mdFormat = "html"
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.GlobToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.GrepToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.LSToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.SourcegraphToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.ViewToolName:
|
||||
metadata := tools.ViewResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
ext := filepath.Ext(metadata.FilePath)
|
||||
if ext == "" {
|
||||
ext = ""
|
||||
} else {
|
||||
ext = strings.ToLower(ext[1:])
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.WriteToolName:
|
||||
params := tools.WriteParams{}
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
metadata := tools.WriteResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
ext := filepath.Ext(params.FilePath)
|
||||
if ext == "" {
|
||||
ext = ""
|
||||
} else {
|
||||
ext = strings.ToLower(ext[1:])
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
default:
|
||||
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func renderToolMessage(
|
||||
toolCall message.ToolCall,
|
||||
allMessages []message.Message,
|
||||
messagesService message.Service,
|
||||
focusedUIMessageId string,
|
||||
nested bool,
|
||||
width int,
|
||||
position int,
|
||||
) uiMessage {
|
||||
if nested {
|
||||
width = width - 3
|
||||
}
|
||||
response := findToolResponse(toolCall.ID, allMessages)
|
||||
toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
|
||||
params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
|
||||
responseContent := ""
|
||||
if response != nil {
|
||||
responseContent = renderToolResponse(toolCall, *response, width-2)
|
||||
responseContent = strings.TrimSuffix(responseContent, "\n")
|
||||
} else {
|
||||
responseContent = styles.BaseStyle.
|
||||
Italic(true).
|
||||
Width(width - 2).
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render("Waiting for response...")
|
||||
}
|
||||
style := styles.BaseStyle.
|
||||
Width(width - 1).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
BorderForeground(styles.ForgroundDim)
|
||||
|
||||
parts := []string{}
|
||||
if !nested {
|
||||
params := styles.BaseStyle.
|
||||
Width(width - 2 - lipgloss.Width(toolName)).
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render(params)
|
||||
|
||||
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
|
||||
} else {
|
||||
prefix := styles.BaseStyle.
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render(" └ ")
|
||||
params := styles.BaseStyle.
|
||||
Width(width - 2 - lipgloss.Width(toolName)).
|
||||
Foreground(styles.ForgroundMid).
|
||||
Render(params)
|
||||
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
|
||||
}
|
||||
if toolCall.Name == agent.AgentToolName {
|
||||
taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
|
||||
toolCalls := []message.ToolCall{}
|
||||
for _, v := range taskMessages {
|
||||
toolCalls = append(toolCalls, v.ToolCalls()...)
|
||||
}
|
||||
for _, call := range toolCalls {
|
||||
rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
|
||||
parts = append(parts, rendered.content)
|
||||
}
|
||||
}
|
||||
if responseContent != "" && !nested {
|
||||
parts = append(parts, responseContent)
|
||||
}
|
||||
|
||||
content := style.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
),
|
||||
)
|
||||
if nested {
|
||||
content = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
)
|
||||
}
|
||||
toolMsg := uiMessage{
|
||||
messageType: toolMessageType,
|
||||
position: position,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
}
|
||||
return toolMsg
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/kujtimiihoxha/opencode/internal/app"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/agent"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/models"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
|
||||
"github.com/kujtimiihoxha/opencode/internal/logging"
|
||||
"github.com/kujtimiihoxha/opencode/internal/message"
|
||||
"github.com/kujtimiihoxha/opencode/internal/pubsub"
|
||||
"github.com/kujtimiihoxha/opencode/internal/session"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
type uiMessageType int
|
||||
|
||||
const (
|
||||
userMessageType uiMessageType = iota
|
||||
assistantMessageType
|
||||
toolMessageType
|
||||
)
|
||||
|
||||
// messagesTickMsg is a message sent by the timer to refresh messages
|
||||
type messagesTickMsg time.Time
|
||||
|
||||
type uiMessage struct {
|
||||
ID string
|
||||
messageType uiMessageType
|
||||
position int
|
||||
height int
|
||||
content string
|
||||
}
|
||||
|
||||
type messagesCmp struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
writingMode bool
|
||||
viewport viewport.Model
|
||||
session session.Session
|
||||
messages []message.Message
|
||||
uiMessages []uiMessage
|
||||
currentMsgID string
|
||||
renderer *glamour.TermRenderer
|
||||
focusRenderer *glamour.TermRenderer
|
||||
cachedContent map[string]string
|
||||
spinner spinner.Model
|
||||
needsRerender bool
|
||||
}
|
||||
|
||||
func (m *messagesCmp) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
|
||||
}
|
||||
|
||||
func (m *messagesCmp) tickMessages() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return messagesTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case messagesTickMsg:
|
||||
// Refresh messages if we have an active session
|
||||
if m.session.ID != "" {
|
||||
messages, err := m.app.Messages.List(context.Background(), m.session.ID)
|
||||
if err == nil {
|
||||
m.messages = messages
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
// Continue ticking
|
||||
cmds = append(cmds, m.tickMessages())
|
||||
case EditorFocusMsg:
|
||||
m.writingMode = bool(msg)
|
||||
case SessionSelectedMsg:
|
||||
if msg.ID != m.session.ID {
|
||||
cmd := m.SetSession(msg)
|
||||
m.needsRerender = true
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
case SessionClearedMsg:
|
||||
m.session = session.Session{}
|
||||
m.messages = make([]message.Message, 0)
|
||||
m.currentMsgID = ""
|
||||
m.needsRerender = true
|
||||
m.cachedContent = make(map[string]string)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.writingMode {
|
||||
return m, nil
|
||||
}
|
||||
case pubsub.Event[message.Message]:
|
||||
if msg.Type == pubsub.CreatedEvent {
|
||||
if msg.Payload.SessionID == m.session.ID {
|
||||
// check if message exists
|
||||
|
||||
messageExists := false
|
||||
for _, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
messageExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !messageExists {
|
||||
// If we have messages, ensure the previous last message is not cached
|
||||
if len(m.messages) > 0 {
|
||||
lastMsgID := m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, lastMsgID)
|
||||
}
|
||||
|
||||
m.messages = append(m.messages, msg.Payload)
|
||||
delete(m.cachedContent, m.currentMsgID)
|
||||
m.currentMsgID = msg.Payload.ID
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
for _, v := range m.messages {
|
||||
for _, c := range v.ToolCalls() {
|
||||
if c.ID == msg.Payload.SessionID {
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
|
||||
logging.Debug("Message", "finish", msg.Payload.FinishReason())
|
||||
for i, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
m.messages[i] = msg.Payload
|
||||
delete(m.cachedContent, msg.Payload.ID)
|
||||
|
||||
// If this is the last message, ensure it's not cached
|
||||
if i == len(m.messages)-1 {
|
||||
delete(m.cachedContent, msg.Payload.ID)
|
||||
}
|
||||
|
||||
m.needsRerender = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldPos := m.viewport.YPosition
|
||||
u, cmd := m.viewport.Update(msg)
|
||||
m.viewport = u
|
||||
m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
spinner, cmd := m.spinner.Update(msg)
|
||||
m.spinner = spinner
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if m.needsRerender {
|
||||
m.renderView()
|
||||
if len(m.messages) > 0 {
|
||||
if msg, ok := msg.(pubsub.Event[message.Message]); ok {
|
||||
if (msg.Type == pubsub.CreatedEvent) ||
|
||||
(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
m.needsRerender = false
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) IsAgentWorking() bool {
|
||||
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
|
||||
// Check if this is the last message in the list
|
||||
isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
|
||||
|
||||
// Only use cache for non-last messages
|
||||
if !isLastMessage {
|
||||
if v, ok := m.cachedContent[msg.ID]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
style := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
BorderLeft(true).
|
||||
Foreground(styles.ForgroundDim).
|
||||
BorderForeground(styles.ForgroundDim).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
renderer := m.renderer
|
||||
if msg.ID == m.currentMsgID {
|
||||
style = style.
|
||||
Foreground(styles.Forground).
|
||||
BorderForeground(styles.Blue).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
renderer = m.focusRenderer
|
||||
}
|
||||
c, _ := renderer.Render(msg.Content().String())
|
||||
parts := []string{
|
||||
styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
|
||||
}
|
||||
// remove newline at the end
|
||||
parts[0] = strings.TrimSuffix(parts[0], "\n")
|
||||
if len(info) > 0 {
|
||||
parts = append(parts, info...)
|
||||
}
|
||||
rendered := style.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
),
|
||||
)
|
||||
|
||||
// Only cache if it's not the last message
|
||||
if !isLastMessage {
|
||||
m.cachedContent[msg.ID] = rendered
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
func formatTimeDifference(unixTime1, unixTime2 int64) string {
|
||||
diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
|
||||
|
||||
if diffSeconds < 60 {
|
||||
return fmt.Sprintf("%.1fs", diffSeconds)
|
||||
}
|
||||
|
||||
minutes := int(diffSeconds / 60)
|
||||
seconds := int(diffSeconds) % 60
|
||||
return fmt.Sprintf("%dm%ds", minutes, seconds)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
|
||||
for _, v := range m.messages {
|
||||
for _, c := range v.ToolResults() {
|
||||
if c.ToolCallID == callID {
|
||||
return &c
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
|
||||
key := ""
|
||||
value := ""
|
||||
result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
|
||||
|
||||
response := m.findToolResponse(toolCall.ID)
|
||||
if response != nil && response.IsError {
|
||||
// Clean up error message for display by removing newlines
|
||||
// This ensures error messages display properly in the UI
|
||||
errMsg := strings.ReplaceAll(response.Content, "\n", " ")
|
||||
result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
|
||||
} else if response != nil {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
|
||||
}
|
||||
switch toolCall.Name {
|
||||
// TODO: add result data to the tools
|
||||
case agent.AgentToolName:
|
||||
key = "Task"
|
||||
var params agent.AgentParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = strings.ReplaceAll(params.Prompt, "\n", " ")
|
||||
if response != nil && !response.IsError {
|
||||
firstRow := strings.ReplaceAll(response.Content, "\n", " ")
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
|
||||
}
|
||||
case tools.BashToolName:
|
||||
key = "Bash"
|
||||
var params tools.BashParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.Command
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.BashResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
|
||||
}
|
||||
|
||||
case tools.EditToolName:
|
||||
key = "Edit"
|
||||
var params tools.EditParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.EditResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
|
||||
}
|
||||
case tools.FetchToolName:
|
||||
key = "Fetch"
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.URL
|
||||
if response != nil && !response.IsError {
|
||||
result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
|
||||
}
|
||||
case tools.GlobToolName:
|
||||
key = "Glob"
|
||||
var params tools.GlobParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.GlobResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
|
||||
}
|
||||
}
|
||||
case tools.GrepToolName:
|
||||
key = "Grep"
|
||||
var params tools.GrepParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.GrepResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
|
||||
}
|
||||
}
|
||||
case tools.LSToolName:
|
||||
key = "ls"
|
||||
var params tools.LSParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = params.Path
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.LSResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
|
||||
}
|
||||
}
|
||||
case tools.SourcegraphToolName:
|
||||
key = "Sourcegraph"
|
||||
var params tools.SourcegraphParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.Query
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.SourcegraphResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
|
||||
}
|
||||
}
|
||||
case tools.ViewToolName:
|
||||
key = "View"
|
||||
var params tools.ViewParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
case tools.WriteToolName:
|
||||
key = "Write"
|
||||
var params tools.WriteParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.WriteResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
|
||||
}
|
||||
default:
|
||||
key = toolCall.Name
|
||||
var params map[string]any
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
jsonData, _ := json.Marshal(params)
|
||||
value = string(jsonData)
|
||||
}
|
||||
|
||||
style := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
BorderForeground(styles.Yellow)
|
||||
|
||||
keyStyle := styles.BaseStyle.
|
||||
Foreground(styles.ForgroundDim)
|
||||
valyeStyle := styles.BaseStyle.
|
||||
Foreground(styles.Forground)
|
||||
|
||||
if isNested {
|
||||
valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
|
||||
}
|
||||
keyValye := keyStyle.Render(
|
||||
fmt.Sprintf("%s: ", key),
|
||||
)
|
||||
if !isNested {
|
||||
value = valyeStyle.
|
||||
Render(
|
||||
ansi.Truncate(
|
||||
value+" ",
|
||||
m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
|
||||
"...",
|
||||
),
|
||||
)
|
||||
value += result
|
||||
|
||||
} else {
|
||||
keyValye = keyStyle.Render(
|
||||
fmt.Sprintf(" └ %s: ", key),
|
||||
)
|
||||
value = valyeStyle.
|
||||
Width(m.width - lipgloss.Width(keyValye) - 2).
|
||||
Render(
|
||||
ansi.Truncate(
|
||||
value,
|
||||
m.width-lipgloss.Width(keyValye)-2,
|
||||
"...",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
innerToolCalls := make([]string, 0)
|
||||
if toolCall.Name == agent.AgentToolName {
|
||||
messages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
|
||||
toolCalls := make([]message.ToolCall, 0)
|
||||
for _, v := range messages {
|
||||
toolCalls = append(toolCalls, v.ToolCalls()...)
|
||||
}
|
||||
for _, v := range toolCalls {
|
||||
call := m.renderToolCall(v, true)
|
||||
innerToolCalls = append(innerToolCalls, call)
|
||||
}
|
||||
}
|
||||
|
||||
if isNested {
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
keyValye,
|
||||
value,
|
||||
)
|
||||
}
|
||||
callContent := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
keyValye,
|
||||
value,
|
||||
)
|
||||
callContent = strings.ReplaceAll(callContent, "\n", "")
|
||||
if len(innerToolCalls) > 0 {
|
||||
callContent = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
callContent,
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
innerToolCalls...,
|
||||
),
|
||||
)
|
||||
}
|
||||
return style.Render(callContent)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
|
||||
// find the user message that is before this assistant message
|
||||
var userMsg message.Message
|
||||
for i := len(m.messages) - 1; i >= 0; i-- {
|
||||
if m.messages[i].Role == message.User {
|
||||
userMsg = m.messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
messages := make([]uiMessage, 0)
|
||||
if msg.Content().String() != "" {
|
||||
info := make([]string, 0)
|
||||
if msg.IsFinished() && msg.FinishReason() == "end_turn" {
|
||||
finish := msg.FinishPart()
|
||||
took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
|
||||
|
||||
info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
|
||||
))
|
||||
}
|
||||
content := m.renderSimpleMessage(msg, info...)
|
||||
messages = append(messages, uiMessage{
|
||||
messageType: assistantMessageType,
|
||||
position: 0, // gets updated in renderView
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
for _, v := range msg.ToolCalls() {
|
||||
content := m.renderToolCall(v, false)
|
||||
messages = append(messages,
|
||||
uiMessage{
|
||||
messageType: toolMessageType,
|
||||
position: 0, // gets updated in renderView
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderView() {
|
||||
m.uiMessages = make([]uiMessage, 0)
|
||||
pos := 0
|
||||
|
||||
// If we have messages, ensure the last message is not cached
|
||||
// This ensures we always render the latest content for the most recent message
|
||||
// which may be actively updating (e.g., during generation)
|
||||
if len(m.messages) > 0 {
|
||||
lastMsgID := m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, lastMsgID)
|
||||
}
|
||||
|
||||
// Limit cache to 10 messages
|
||||
if len(m.cachedContent) > 15 {
|
||||
// Create a list of keys to delete (oldest messages first)
|
||||
keys := make([]string, 0, len(m.cachedContent))
|
||||
for k := range m.cachedContent {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// Delete oldest messages until we have 10 or fewer
|
||||
for i := 0; i < len(keys)-15; i++ {
|
||||
delete(m.cachedContent, keys[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range m.messages {
|
||||
switch v.Role {
|
||||
case message.User:
|
||||
content := m.renderSimpleMessage(v)
|
||||
m.uiMessages = append(m.uiMessages, uiMessage{
|
||||
messageType: userMessageType,
|
||||
position: pos,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
})
|
||||
pos += lipgloss.Height(content) + 1 // + 1 for spacing
|
||||
case message.Assistant:
|
||||
assistantMessages := m.renderAssistantMessage(v)
|
||||
for _, msg := range assistantMessages {
|
||||
msg.position = pos
|
||||
m.uiMessages = append(m.uiMessages, msg)
|
||||
pos += msg.height + 1 // + 1 for spacing
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, v := range m.uiMessages {
|
||||
messages = append(messages, v.content,
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
"",
|
||||
),
|
||||
)
|
||||
}
|
||||
m.viewport.SetContent(
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
messages...,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) View() string {
|
||||
if len(m.messages) == 0 {
|
||||
content := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Height(m.height - 1).
|
||||
Render(
|
||||
m.initialScreen(),
|
||||
)
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
content,
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
m.viewport.View(),
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) help() string {
|
||||
text := ""
|
||||
|
||||
if m.IsAgentWorking() {
|
||||
text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
|
||||
fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
|
||||
)
|
||||
}
|
||||
if m.writingMode {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
|
||||
)
|
||||
} else {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(text)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) initialScreen() string {
|
||||
return styles.BaseStyle.Width(m.width).Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
header(m.width),
|
||||
"",
|
||||
lspsConfigured(m.width),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height - 1
|
||||
focusRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(true)),
|
||||
glamour.WithWordWrap(width-1),
|
||||
)
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(false)),
|
||||
glamour.WithWordWrap(width-1),
|
||||
)
|
||||
m.focusRenderer = focusRenderer
|
||||
// clear the cached content
|
||||
for k := range m.cachedContent {
|
||||
delete(m.cachedContent, k)
|
||||
}
|
||||
m.renderer = renderer
|
||||
if len(m.messages) > 0 {
|
||||
m.renderView()
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesCmp) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
|
||||
m.session = session
|
||||
messages, err := m.app.Messages.List(context.Background(), session.ID)
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
m.messages = messages
|
||||
m.currentMsgID = m.messages[len(m.messages)-1].ID
|
||||
m.needsRerender = true
|
||||
m.cachedContent = make(map[string]string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *messagesCmp) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewMessagesCmp(app *app.App) tea.Model {
|
||||
focusRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(true)),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(false)),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Pulse
|
||||
return &messagesCmp{
|
||||
app: app,
|
||||
writingMode: true,
|
||||
cachedContent: make(map[string]string),
|
||||
viewport: viewport.New(0, 0),
|
||||
focusRenderer: focusRenderer,
|
||||
renderer: renderer,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
224
internal/tui/components/dialog/session.go
Normal file
224
internal/tui/components/dialog/session.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package styles
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
|
||||
return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
|
||||
}
|
||||
|
||||
// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
|
||||
// in `input` with a single 24‑bit background (48;2;R;G;B).
|
||||
func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
|
||||
// Precompute our new-bg sequence once
|
||||
r, g, b := getColorRGB(newBgColor)
|
||||
|
||||
newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
|
||||
|
||||
return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
|
||||
// Extract content between "\x1b[" and "m"
|
||||
content := seq[2 : len(seq)-1]
|
||||
tokens := strings.Split(content, ";")
|
||||
var newTokens []string
|
||||
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
|
||||
// fast‑path: skip "48;5;N" or "48;2;R;G;B"
|
||||
if len(token) == 2 && token[0] == '4' && token[1] == '8' {
|
||||
k := j + 1
|
||||
if k < end {
|
||||
// find next token
|
||||
l := k
|
||||
for l < end && raw[l] != ';' {
|
||||
l++
|
||||
}
|
||||
next := raw[k:l]
|
||||
if next == "5" {
|
||||
// skip "48;5;N"
|
||||
m := l + 1
|
||||
for m < end && raw[m] != ';' {
|
||||
m++
|
||||
}
|
||||
i = m + 1
|
||||
continue
|
||||
} else if next == "2" {
|
||||
// skip "48;2;R;G;B"
|
||||
m := l + 1
|
||||
for count := 0; count < 3 && m < end; count++ {
|
||||
for m < end && raw[m] != ';' {
|
||||
m++
|
||||
}
|
||||
m++
|
||||
}
|
||||
i = m
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
|
||||
// Keep non-background tokens
|
||||
newTokens = append(newTokens, tokens[i])
|
||||
}
|
||||
|
||||
// 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"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user