summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-12 02:01:45 +0200
committerKujtim Hoxha <[email protected]>2025-04-21 13:38:42 +0200
commit8d874b839db169906e18e4277cd198504018e022 (patch)
tree658a1adbee42a0ca2249252827fb37f7469f3667 /internal/tui/components
parent08bd75bb6e1fde0427dfd37204ee9a3c43bb1e5b (diff)
downloadopencode-8d874b839db169906e18e4277cd198504018e022.tar.gz
opencode-8d874b839db169906e18e4277cd198504018e022.zip
add initial message handling
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/chat/chat.go113
-rw-r--r--internal/tui/components/chat/editor.go65
-rw-r--r--internal/tui/components/chat/messages.go339
-rw-r--r--internal/tui/components/chat/sidebar.go133
-rw-r--r--internal/tui/components/core/button.go287
5 files changed, 512 insertions, 425 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
new file mode 100644
index 000000000..e893ec2f5
--- /dev/null
+++ b/internal/tui/components/chat/chat.go
@@ -0,0 +1,113 @@
+package chat
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/termai/internal/config"
+ "github.com/kujtimiihoxha/termai/internal/session"
+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
+ "github.com/kujtimiihoxha/termai/internal/version"
+)
+
+type SendMsg struct {
+ Text string
+}
+
+type SessionSelectedMsg = session.Session
+
+type SessionClearedMsg struct{}
+
+type AgentWorkingMsg bool
+
+type EditorFocusMsg bool
+
+func lspsConfigured(width int) string {
+ cfg := config.Get()
+ title := "LSP Configuration"
+ title = ansi.Truncate(title, width, "…")
+
+ lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
+
+ var lspViews []string
+ for name, lsp := range cfg.LSP {
+ lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
+ fmt.Sprintf("• %s", name),
+ )
+ cmd := lsp.Command
+ cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
+ lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" (%s)", cmd),
+ )
+ lspViews = append(lspViews,
+ styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ lspName,
+ lspPath,
+ ),
+ ),
+ )
+
+ }
+ return styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lsps,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lspViews...,
+ ),
+ ),
+ )
+}
+
+func logo(width int) string {
+ logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+
+ version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
+
+ return styles.BaseStyle.
+ Bold(true).
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ logo,
+ " ",
+ version,
+ ),
+ )
+}
+
+func repo(width int) string {
+ repo := "https://github.com/kujtimiihoxha/opencode"
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(repo)
+}
+
+func cwd(width int) string {
+ cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(cwd)
+}
+
+func header(width int) string {
+ header := lipgloss.JoinVertical(
+ lipgloss.Top,
+ logo(width),
+ repo(width),
+ "",
+ cwd(width),
+ )
+ return header
+}
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index ea20d7e44..df336818c 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -7,10 +7,12 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
+ "github.com/kujtimiihoxha/termai/internal/tui/util"
)
type editorCmp struct {
- textarea textarea.Model
+ textarea textarea.Model
+ agentWorking bool
}
type focusedEditorKeyMaps struct {
@@ -49,39 +51,51 @@ func (m *editorCmp) Init() tea.Cmd {
return textarea.Blink
}
+func (m *editorCmp) send() tea.Cmd {
+ if m.agentWorking {
+ return util.ReportWarn("Agent is working, please wait...")
+ }
+
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ m.textarea.Blur()
+ if value == "" {
+ return nil
+ }
+ return tea.Batch(
+ util.CmdHandler(SendMsg{
+ Text: value,
+ }),
+ util.CmdHandler(AgentWorkingMsg(true)),
+ util.CmdHandler(EditorFocusMsg(false)),
+ )
+}
+
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
- if m.textarea.Focused() {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- if key.Matches(msg, focusedKeyMaps.Send) {
- // TODO: send message
- m.textarea.Reset()
- m.textarea.Blur()
- return m, nil
- }
- if key.Matches(msg, focusedKeyMaps.Blur) {
- m.textarea.Blur()
- return m, nil
- }
- }
- m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
- }
switch msg := msg.(type) {
+ case AgentWorkingMsg:
+ m.agentWorking = bool(msg)
case tea.KeyMsg:
+ if key.Matches(msg, focusedKeyMaps.Send) {
+ return m, m.send()
+ }
if key.Matches(msg, bluredKeyMaps.Send) {
- // TODO: send message
- m.textarea.Reset()
- return m, nil
+ return m, m.send()
+ }
+ if key.Matches(msg, focusedKeyMaps.Blur) {
+ m.textarea.Blur()
+ return m, util.CmdHandler(EditorFocusMsg(false))
}
if key.Matches(msg, bluredKeyMaps.Focus) {
- m.textarea.Focus()
- return m, textarea.Blink
+ if !m.textarea.Focused() {
+ m.textarea.Focus()
+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+ }
}
}
-
- return m, nil
+ m.textarea, cmd = m.textarea.Update(msg)
+ return m, cmd
}
func (m *editorCmp) View() string {
@@ -122,6 +136,7 @@ func NewEditorCmp() tea.Model {
ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+ ti.CharLimit = -1
ti.Focus()
return &editorCmp{
textarea: ti,
diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go
index 691954767..0a7e6e2a4 100644
--- a/internal/tui/components/chat/messages.go
+++ b/internal/tui/components/chat/messages.go
@@ -1,21 +1,344 @@
package chat
-import tea "github.com/charmbracelet/bubbletea"
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
-type messagesCmp struct{}
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/app"
+ "github.com/kujtimiihoxha/termai/internal/message"
+ "github.com/kujtimiihoxha/termai/internal/pubsub"
+ "github.com/kujtimiihoxha/termai/internal/session"
+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
+ "github.com/kujtimiihoxha/termai/internal/tui/util"
+)
+
+type uiMessage struct {
+ 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
+ currentIndex int
+ renderer *glamour.TermRenderer
+ focusRenderer *glamour.TermRenderer
+ cachedContent map[string]string
+}
func (m *messagesCmp) Init() tea.Cmd {
- return nil
+ return m.viewport.Init()
+}
+
+var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
+
+func hexToBgSGR(hex string) (string, error) {
+ hex = strings.TrimPrefix(hex, "#")
+ if len(hex) != 6 {
+ return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits")
+ }
+
+ // Parse RGB components in one block
+ rgb := make([]uint64, 3)
+ for i := 0; i < 3; i++ {
+ val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8)
+ if err != nil {
+ return "", err
+ }
+ rgb[i] = val
+ }
+
+ return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil
+}
+
+func forceReplaceBackgroundColors(input string, newBg string) string {
+ return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
+ // Extract content between "\x1b[" and "m"
+ content := seq[2 : len(seq)-1]
+ tokens := strings.Split(content, ";")
+ var newTokens []string
+
+ // Skip background color tokens
+ for i := 0; i < len(tokens); i++ {
+ if tokens[i] == "" {
+ continue
+ }
+
+ 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
+ }
+ }
+ }
+ } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
+ // Keep non-background tokens
+ newTokens = append(newTokens, tokens[i])
+ }
+ }
+
+ // Add new background if provided
+ if newBg != "" {
+ newTokens = append(newTokens, strings.Split(newBg, ";")...)
+ }
+
+ if len(newTokens) == 0 {
+ return ""
+ }
+
+ return "\x1b[" + strings.Join(newTokens, ";") + "m"
+ })
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, 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 pubsub.Event[message.Message]:
+ if msg.Type == pubsub.CreatedEvent {
+ if msg.Payload.SessionID == m.session.ID {
+ // check if message exists
+ for _, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ return m, nil
+ }
+ }
+
+ m.messages = append(m.messages, msg.Payload)
+ m.renderView()
+ m.viewport.GotoBottom()
+ }
+ for _, v := range m.messages {
+ for _, c := range v.ToolCalls() {
+ // the message is being added to the session of a tool called
+ if c.ID == msg.Payload.SessionID {
+ m.renderView()
+ m.viewport.GotoBottom()
+ }
+ }
+ }
+ } 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)
+ m.renderView()
+ if i == len(m.messages)-1 {
+ m.viewport.GotoBottom()
+ }
+ break
+ }
+ }
+ }
+ }
+ u, cmd := m.viewport.Update(msg)
+ m.viewport = u
+ return m, cmd
}
-func (m *messagesCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
- return m, nil
+func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
+ 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 inx == m.currentIndex {
+ style = style.
+ Foreground(styles.Forground).
+ BorderForeground(styles.Blue).
+ BorderStyle(lipgloss.ThickBorder())
+ renderer = m.focusRenderer
+ }
+ c, _ := renderer.Render(msg.Content().String())
+ col, _ := hexToBgSGR(styles.Background.Dark)
+ rendered := style.Render(forceReplaceBackgroundColors(c, col))
+ m.cachedContent[msg.ID] = rendered
+ return rendered
+}
+
+func (m *messagesCmp) renderView() {
+ m.uiMessages = make([]uiMessage, 0)
+ pos := 0
+
+ for _, v := range m.messages {
+ content := ""
+ switch v.Role {
+ case message.User:
+ content = m.renderUserMessage(pos, v)
+ }
+ m.uiMessages = append(m.uiMessages, uiMessage{
+ position: pos,
+ height: lipgloss.Height(content),
+ content: content,
+ })
+ pos += lipgloss.Height(content) + 1 // + 1 for spacing
+ }
+
+ messages := make([]string, 0)
+ for _, v := range m.uiMessages {
+ messages = append(messages, v.content)
+ }
+ m.viewport.SetContent(
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ messages...,
+ ),
+ ),
+ )
}
func (m *messagesCmp) View() string {
- return "Messages"
+ 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(),
+ ),
+ )
+ }
+
+ m.renderView()
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ m.viewport.View(),
+ m.help(),
+ ),
+ )
+}
+
+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) {
+ 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
+ m.renderer = renderer
+}
+
+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(session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ m.messages = messages
+ m.messages = append(m.messages, m.messages[0])
+ return nil
}
-func NewMessagesCmp() tea.Model {
- return &messagesCmp{}
+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),
+ )
+ return &messagesCmp{
+ app: app,
+ writingMode: true,
+ cachedContent: make(map[string]string),
+ viewport: viewport.New(0, 0),
+ focusRenderer: focusRenderer,
+ renderer: renderer,
+ }
}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index 4a5631577..65c06f4a1 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -5,40 +5,43 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/config"
+ "github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/version"
)
type sidebarCmp struct {
width, height int
+ session session.Session
}
func (m *sidebarCmp) Init() tea.Cmd {
return nil
}
-func (m *sidebarCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *sidebarCmp) View() string {
- return styles.BaseStyle.Width(m.width).Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- m.header(),
- " ",
- m.session(),
- " ",
- m.modifiedFiles(),
- " ",
- m.lspsConfigured(),
- ),
- )
+ return styles.BaseStyle.
+ Width(m.width).
+ Height(m.height - 1).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ " ",
+ m.sessionSection(),
+ " ",
+ m.modifiedFiles(),
+ " ",
+ lspsConfigured(m.width),
+ ),
+ )
}
-func (m *sidebarCmp) session() string {
- sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Render("Session")
+func (m *sidebarCmp) sessionSection() string {
+ sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
sessionValue := styles.BaseStyle.
Foreground(styles.Forground).
Width(m.width - lipgloss.Width(sessionKey)).
@@ -53,11 +56,11 @@ func (m *sidebarCmp) session() string {
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
stats := ""
if additions > 0 && removals > 0 {
- stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions and %d removals", additions, removals))
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
} else if additions > 0 {
- stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions", additions))
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
} else if removals > 0 {
- stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d removals", removals))
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
}
filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
@@ -67,60 +70,13 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
lipgloss.JoinHorizontal(
lipgloss.Left,
filePathStr,
- " ",
stats,
),
)
}
-func (m *sidebarCmp) lspsConfigured() string {
- lsps := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("LSP Configuration:")
- lspsConfigured := []struct {
- name string
- path string
- }{
- {"golsp", "path/to/lsp1"},
- {"vtsls", "path/to/lsp2"},
- }
-
- var lspViews []string
- for _, lsp := range lspsConfigured {
- lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
- fmt.Sprintf("• %s", lsp.name),
- )
- lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
- fmt.Sprintf("(%s)", lsp.path),
- )
- lspViews = append(lspViews,
- styles.BaseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- lspName,
- " ",
- lspPath,
- ),
- ),
- )
-
- }
- return styles.BaseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lsps,
- lipgloss.JoinVertical(
- lipgloss.Left,
- lspViews...,
- ),
- ),
- )
-}
-
func (m *sidebarCmp) modifiedFiles() string {
- modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("Modified Files:")
+ modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
files := []struct {
path string
additions int
@@ -149,41 +105,6 @@ func (m *sidebarCmp) modifiedFiles() string {
)
}
-func (m *sidebarCmp) logo() string {
- logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
-
- version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
-
- return styles.BaseStyle.
- Bold(true).
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- logo,
- " ",
- version,
- ),
- )
-}
-
-func (m *sidebarCmp) header() string {
- header := lipgloss.JoinVertical(
- lipgloss.Top,
- m.logo(),
- m.cwd(),
- )
- return header
-}
-
-func (m *sidebarCmp) cwd() string {
- cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
- return styles.BaseStyle.
- Foreground(styles.ForgroundDim).
- Width(m.width).
- Render(cwd)
-}
-
func (m *sidebarCmp) SetSize(width, height int) {
m.width = width
m.height = height
@@ -193,6 +114,8 @@ func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
-func NewSidebarCmp() tea.Model {
- return &sidebarCmp{}
+func NewSidebarCmp(session session.Session) tea.Model {
+ return &sidebarCmp{
+ session: session,
+ }
}
diff --git a/internal/tui/components/core/button.go b/internal/tui/components/core/button.go
deleted file mode 100644
index 090fbc1ee..000000000
--- a/internal/tui/components/core/button.go
+++ /dev/null
@@ -1,287 +0,0 @@
-package core
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-// ButtonKeyMap defines key bindings for the button component
-type ButtonKeyMap struct {
- Enter key.Binding
-}
-
-// DefaultButtonKeyMap returns default key bindings for the button
-func DefaultButtonKeyMap() ButtonKeyMap {
- return ButtonKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- }
-}
-
-// ShortHelp returns keybinding help
-func (k ButtonKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{k.Enter}
-}
-
-// FullHelp returns full help info for keybindings
-func (k ButtonKeyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{
- {k.Enter},
- }
-}
-
-// ButtonState represents the state of a button
-type ButtonState int
-
-const (
- // ButtonNormal is the default state
- ButtonNormal ButtonState = iota
- // ButtonHovered is when the button is focused/hovered
- ButtonHovered
- // ButtonPressed is when the button is being pressed
- ButtonPressed
- // ButtonDisabled is when the button is disabled
- ButtonDisabled
-)
-
-// ButtonVariant defines the visual style variant of a button
-type ButtonVariant int
-
-const (
- // ButtonPrimary uses primary color styling
- ButtonPrimary ButtonVariant = iota
- // ButtonSecondary uses secondary color styling
- ButtonSecondary
- // ButtonDanger uses danger/error color styling
- ButtonDanger
- // ButtonWarning uses warning color styling
- ButtonWarning
- // ButtonNeutral uses neutral color styling
- ButtonNeutral
-)
-
-// ButtonMsg is sent when a button is clicked
-type ButtonMsg struct {
- ID string
- Payload any
-}
-
-// ButtonCmp represents a clickable button component
-type ButtonCmp struct {
- id string
- label string
- width int
- height int
- state ButtonState
- variant ButtonVariant
- keyMap ButtonKeyMap
- payload any
- style lipgloss.Style
- hoverStyle lipgloss.Style
-}
-
-// NewButtonCmp creates a new button component
-func NewButtonCmp(id, label string) *ButtonCmp {
- b := &ButtonCmp{
- id: id,
- label: label,
- state: ButtonNormal,
- variant: ButtonPrimary,
- keyMap: DefaultButtonKeyMap(),
- width: len(label) + 4, // add some padding
- height: 1,
- }
- b.updateStyles()
- return b
-}
-
-// WithVariant sets the button variant
-func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
- b.variant = variant
- b.updateStyles()
- return b
-}
-
-// WithPayload sets the payload sent with button events
-func (b *ButtonCmp) WithPayload(payload any) *ButtonCmp {
- b.payload = payload
- return b
-}
-
-// WithWidth sets a custom width
-func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
- b.width = width
- b.updateStyles()
- return b
-}
-
-// updateStyles recalculates styles based on current state and variant
-func (b *ButtonCmp) updateStyles() {
- // Base styles
- b.style = styles.Regular.
- Padding(0, 1).
- Width(b.width).
- Align(lipgloss.Center).
- BorderStyle(lipgloss.RoundedBorder())
-
- b.hoverStyle = b.style.
- Bold(true)
-
- // Variant-specific styling
- switch b.variant {
- case ButtonPrimary:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Primary).
- BorderForeground(styles.Primary)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Blue).
- BorderForeground(styles.Blue)
-
- case ButtonSecondary:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Secondary).
- BorderForeground(styles.Secondary)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Mauve).
- BorderForeground(styles.Mauve)
-
- case ButtonDanger:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Error).
- BorderForeground(styles.Error)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Red).
- BorderForeground(styles.Red)
-
- case ButtonWarning:
- b.style = b.style.
- Foreground(styles.Text).
- Background(styles.Warning).
- BorderForeground(styles.Warning)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Text).
- Background(styles.Peach).
- BorderForeground(styles.Peach)
-
- case ButtonNeutral:
- b.style = b.style.
- Foreground(styles.Text).
- Background(styles.Grey).
- BorderForeground(styles.Grey)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Text).
- Background(styles.DarkGrey).
- BorderForeground(styles.DarkGrey)
- }
-
- // Disabled style override
- if b.state == ButtonDisabled {
- b.style = b.style.
- Foreground(styles.SubText0).
- Background(styles.LightGrey).
- BorderForeground(styles.LightGrey)
- }
-}
-
-// SetSize sets the button size
-func (b *ButtonCmp) SetSize(width, height int) {
- b.width = width
- b.height = height
- b.updateStyles()
-}
-
-// Focus sets the button to focused state
-func (b *ButtonCmp) Focus() tea.Cmd {
- if b.state != ButtonDisabled {
- b.state = ButtonHovered
- }
- return nil
-}
-
-// Blur sets the button to normal state
-func (b *ButtonCmp) Blur() tea.Cmd {
- if b.state != ButtonDisabled {
- b.state = ButtonNormal
- }
- return nil
-}
-
-// Disable sets the button to disabled state
-func (b *ButtonCmp) Disable() {
- b.state = ButtonDisabled
- b.updateStyles()
-}
-
-// Enable enables the button if disabled
-func (b *ButtonCmp) Enable() {
- if b.state == ButtonDisabled {
- b.state = ButtonNormal
- b.updateStyles()
- }
-}
-
-// IsDisabled returns whether the button is disabled
-func (b *ButtonCmp) IsDisabled() bool {
- return b.state == ButtonDisabled
-}
-
-// IsFocused returns whether the button is focused
-func (b *ButtonCmp) IsFocused() bool {
- return b.state == ButtonHovered
-}
-
-// Init initializes the button
-func (b *ButtonCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update handles messages and user input
-func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- // Skip updates if disabled
- if b.state == ButtonDisabled {
- return b, nil
- }
-
- switch msg := msg.(type) {
- case tea.KeyMsg:
- // Handle key presses when focused
- if b.state == ButtonHovered {
- switch {
- case key.Matches(msg, b.keyMap.Enter):
- b.state = ButtonPressed
- return b, func() tea.Msg {
- return ButtonMsg{
- ID: b.id,
- Payload: b.payload,
- }
- }
- }
- }
- }
-
- return b, nil
-}
-
-// View renders the button
-func (b *ButtonCmp) View() string {
- if b.state == ButtonHovered || b.state == ButtonPressed {
- return b.hoverStyle.Render(b.label)
- }
- return b.style.Render(b.label)
-}
-