diff options
Diffstat (limited to 'internal/tui/components/core')
| -rw-r--r-- | internal/tui/components/core/button.go | 287 | ||||
| -rw-r--r-- | internal/tui/components/core/dialog.go | 117 | ||||
| -rw-r--r-- | internal/tui/components/core/help.go | 119 | ||||
| -rw-r--r-- | internal/tui/components/core/status.go | 192 |
4 files changed, 171 insertions, 544 deletions
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) -} - diff --git a/internal/tui/components/core/dialog.go b/internal/tui/components/core/dialog.go deleted file mode 100644 index a8fef2e86..000000000 --- a/internal/tui/components/core/dialog.go +++ /dev/null @@ -1,117 +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/layout" - "github.com/kujtimiihoxha/termai/internal/tui/util" -) - -type SizeableModel interface { - tea.Model - layout.Sizeable -} - -type DialogMsg struct { - Content SizeableModel - WidthRatio float64 - HeightRatio float64 - - MinWidth int - MinHeight int -} - -type DialogCloseMsg struct{} - -type KeyBindings struct { - Return key.Binding -} - -var keys = KeyBindings{ - Return: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), -} - -type DialogCmp interface { - tea.Model - layout.Bindings -} - -type dialogCmp struct { - content SizeableModel - screenWidth int - screenHeight int - - widthRatio float64 - heightRatio float64 - - minWidth int - minHeight int - - width int - height int -} - -func (d *dialogCmp) Init() tea.Cmd { - return nil -} - -func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - d.screenWidth = msg.Width - d.screenHeight = msg.Height - d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth) - d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight) - if d.content != nil { - d.content.SetSize(d.width, d.height) - } - return d, nil - case DialogMsg: - d.content = msg.Content - d.widthRatio = msg.WidthRatio - d.heightRatio = msg.HeightRatio - d.minWidth = msg.MinWidth - d.minHeight = msg.MinHeight - d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth) - d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight) - if d.content != nil { - d.content.SetSize(d.width, d.height) - } - case DialogCloseMsg: - d.content = nil - return d, nil - case tea.KeyMsg: - if key.Matches(msg, keys.Return) { - return d, util.CmdHandler(DialogCloseMsg{}) - } - } - if d.content != nil { - u, cmd := d.content.Update(msg) - d.content = u.(SizeableModel) - return d, cmd - } - return d, nil -} - -func (d *dialogCmp) BindingKeys() []key.Binding { - bindings := []key.Binding{keys.Return} - if d.content == nil { - return bindings - } - if c, ok := d.content.(layout.Bindings); ok { - return append(bindings, c.BindingKeys()...) - } - return bindings -} - -func (d *dialogCmp) View() string { - return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View()) -} - -func NewDialogCmp() DialogCmp { - return &dialogCmp{} -} diff --git a/internal/tui/components/core/help.go b/internal/tui/components/core/help.go deleted file mode 100644 index 4ef857c78..000000000 --- a/internal/tui/components/core/help.go +++ /dev/null @@ -1,119 +0,0 @@ -package core - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/kujtimiihoxha/termai/internal/tui/styles" -) - -type HelpCmp interface { - tea.Model - SetBindings(bindings []key.Binding) - Height() int -} - -const ( - helpWidgetHeight = 12 -) - -type helpCmp struct { - width int - bindings []key.Binding -} - -func (h *helpCmp) Init() tea.Cmd { - return nil -} - -func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - h.width = msg.Width - } - return h, nil -} - -func (h *helpCmp) View() string { - helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0) - helpDescStyle := styles.Regular.Foreground(styles.Flamingo) - // Compile list of bindings to render - bindings := removeDuplicateBindings(h.bindings) - // Enumerate through each group of bindings, populating a series of - // pairs of columns, one for keys, one for descriptions - var ( - pairs []string - width int - rows = helpWidgetHeight - 2 - ) - for i := 0; i < len(bindings); i += rows { - var ( - keys []string - descs []string - ) - for j := i; j < min(i+rows, len(bindings)); j++ { - keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key)) - descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc)) - } - // Render pair of columns; beyond the first pair, render a three space - // left margin, in order to visually separate the pairs. - var cols []string - if len(pairs) > 0 { - cols = []string{" "} - } - cols = append(cols, - strings.Join(keys, "\n"), - strings.Join(descs, "\n"), - ) - - pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...) - // check whether it exceeds the maximum width avail (the width of the - // terminal, subtracting 2 for the borders). - width += lipgloss.Width(pair) - if width > h.width-2 { - break - } - pairs = append(pairs, pair) - } - - // Join pairs of columns and enclose in a border - content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...) - return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content) -} - -func removeDuplicateBindings(bindings []key.Binding) []key.Binding { - seen := make(map[string]struct{}) - result := make([]key.Binding, 0, len(bindings)) - - // Process bindings in reverse order - for i := len(bindings) - 1; i >= 0; i-- { - b := bindings[i] - k := strings.Join(b.Keys(), " ") - if _, ok := seen[k]; ok { - // duplicate, skip - continue - } - seen[k] = struct{}{} - // Add to the beginning of result to maintain original order - result = append([]key.Binding{b}, result...) - } - - return result -} - -func (h *helpCmp) SetBindings(bindings []key.Binding) { - h.bindings = bindings -} - -func (h helpCmp) Height() int { - return helpWidgetHeight -} - -func NewHelpCmp() HelpCmp { - return &helpCmp{ - width: 0, - bindings: make([]key.Binding, 0), - } -} diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 93ba34507..8bf3e5166 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -1,21 +1,34 @@ package core import ( + "fmt" + "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/tui/styles" - "github.com/kujtimiihoxha/termai/internal/tui/util" - "github.com/kujtimiihoxha/termai/internal/version" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/models" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/lsp/protocol" + "github.com/kujtimiihoxha/opencode/internal/pubsub" + "github.com/kujtimiihoxha/opencode/internal/session" + "github.com/kujtimiihoxha/opencode/internal/tui/components/chat" + "github.com/kujtimiihoxha/opencode/internal/tui/styles" + "github.com/kujtimiihoxha/opencode/internal/tui/util" ) +type StatusCmp interface { + tea.Model + SetHelpMsg(string) +} + type statusCmp struct { info util.InfoMsg width int messageTTL time.Duration + lspClients map[string]*lsp.Client + session session.Session } // clearMessageCmd is a command that clears status messages after a timeout @@ -34,6 +47,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil + case chat.SessionSelectedMsg: + m.session = msg + case chat.SessionClearedMsg: + m.session = session.Session{} + case pubsub.Event[session.Session]: + if msg.Type == pubsub.UpdatedEvent { + if m.session.ID == msg.Payload.ID { + m.session = msg.Payload + } + } case util.InfoMsg: m.info = msg ttl := msg.TTL @@ -47,20 +70,53 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -var ( - versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version) - helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help") -) +var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help") + +func formatTokensAndCost(tokens int64, cost float64) string { + // Format tokens in human-readable format (e.g., 110K, 1.2M) + var formattedTokens string + switch { + case tokens >= 1_000_000: + formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) + default: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + // Remove .0 suffix if present + if strings.HasSuffix(formattedTokens, ".0K") { + formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) + } + if strings.HasSuffix(formattedTokens, ".0M") { + formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) + } + + // Format cost with $ symbol and 2 decimal places + formattedCost := fmt.Sprintf("$%.2f", cost) + + return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost) +} func (m statusCmp) View() string { - status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help") + status := helpWidget + if m.session.ID != "" { + tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost) + tokensStyle := styles.Padded. + Background(styles.Forground). + Foreground(styles.BackgroundDim). + Render(tokens) + status += tokensStyle + } + + diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics()) if m.info.Msg != "" { infoStyle := styles.Padded. Foreground(styles.Base). - Width(m.availableFooterMsgWidth()) + Width(m.availableFooterMsgWidth(diagnostics)) switch m.info.Type { case util.InfoTypeInfo: - infoStyle = infoStyle.Background(styles.Blue) + infoStyle = infoStyle.Background(styles.BorderColor) case util.InfoTypeWarn: infoStyle = infoStyle.Background(styles.Peach) case util.InfoTypeError: @@ -68,7 +124,7 @@ func (m statusCmp) View() string { } // Truncate message if it's longer than available width msg := m.info.Msg - availWidth := m.availableFooterMsgWidth() - 10 + availWidth := m.availableFooterMsgWidth(diagnostics) - 10 if len(msg) > availWidth && availWidth > 0 { msg = msg[:availWidth] + "..." } @@ -76,27 +132,121 @@ func (m statusCmp) View() string { } else { status += styles.Padded. Foreground(styles.Base). - Background(styles.LightGrey). - Width(m.availableFooterMsgWidth()). + Background(styles.BackgroundDim). + Width(m.availableFooterMsgWidth(diagnostics)). Render("") } + + status += diagnostics status += m.model() - status += versionWidget return status } -func (m statusCmp) availableFooterMsgWidth() int { - // -2 to accommodate padding - return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model())) +func (m *statusCmp) projectDiagnostics() string { + // Check if any LSP server is still initializing + initializing := false + for _, client := range m.lspClients { + if client.GetServerState() == lsp.StateStarting { + initializing = true + break + } + } + + // If any server is initializing, show that status + if initializing { + return lipgloss.NewStyle(). + Background(styles.BackgroundDarker). + Foreground(styles.Peach). + Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon)) + } + + errorDiagnostics := []protocol.Diagnostic{} + warnDiagnostics := []protocol.Diagnostic{} + hintDiagnostics := []protocol.Diagnostic{} + infoDiagnostics := []protocol.Diagnostic{} + for _, client := range m.lspClients { + for _, d := range client.GetDiagnostics() { + for _, diag := range d { + switch diag.Severity { + case protocol.SeverityError: + errorDiagnostics = append(errorDiagnostics, diag) + case protocol.SeverityWarning: + warnDiagnostics = append(warnDiagnostics, diag) + case protocol.SeverityHint: + hintDiagnostics = append(hintDiagnostics, diag) + case protocol.SeverityInformation: + infoDiagnostics = append(infoDiagnostics, diag) + } + } + } + } + + if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 { + return "No diagnostics" + } + + diagnostics := []string{} + + if len(errorDiagnostics) > 0 { + 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(). + 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(). + 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(). + Background(styles.BackgroundDarker). + Foreground(styles.Peach). + Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics))) + diagnostics = append(diagnostics, infoStr) + } + + return strings.Join(diagnostics, " ") +} + +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)-tokensWidth) } func (m statusCmp) model() string { - model := models.SupportedModels[config.Get().Model.Coder] + cfg := config.Get() + + coder, ok := cfg.Agents[config.AgentCoder] + if !ok { + return "Unknown" + } + model := models.SupportedModels[coder.Model] return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name) } -func NewStatusCmp() tea.Model { +func (m statusCmp) SetHelpMsg(s string) { + helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s) +} + +func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp { return &statusCmp{ messageTTL: 10 * time.Second, + lspClients: lspClients, } } |
