wip: refactoring tui

This commit is contained in:
adamdottv 2025-06-05 15:44:20 -05:00
parent 979bad3e64
commit 95d5e1f231
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
37 changed files with 1496 additions and 1801 deletions

View file

@ -411,76 +411,6 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) | | `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) | | `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
## Theming
OpenCode supports multiple themes for customizing the appearance of the terminal interface.
### Available Themes
The following predefined themes are available:
- `opencode` (default)
- `catppuccin`
- `dracula`
- `flexoki`
- `gruvbox`
- `monokai`
- `onedark`
- `tokyonight`
- `tron`
- `custom` (user-defined)
### Setting a Theme
You can set a theme in your `.opencode.json` configuration file:
```json
{
"tui": {
"theme": "monokai"
}
}
```
### Custom Themes
You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
```json
{
"tui": {
"theme": "custom",
"customTheme": {
"primary": "#ffcc00",
"secondary": "#00ccff",
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
"error": "#ff0000"
}
}
}
```
#### Color Definition Formats
Custom theme colors support two formats:
1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
#### Available Color Keys
You can define any of the following color keys in your `customTheme`:
- Base colors: `primary`, `secondary`, `accent`
- Status colors: `error`, `warning`, `success`, `info`
- Text colors: `text`, `textMuted`, `textEmphasized`
- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
- Border colors: `borderNormal`, `borderFocused`, `borderDim`
- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
### Shell Configuration ### Shell Configuration
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses: OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:

View file

@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@ -51,7 +52,11 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
app_, err := app.New(ctx, httpClient) version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
app_, err := app.New(ctx, version, httpClient)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View file

@ -21,7 +21,6 @@ import (
type App struct { type App struct {
ConfigPath string ConfigPath string
Config *config.Config Config *config.Config
Info *client.AppInfo
Client *client.ClientWithResponses Client *client.ClientWithResponses
Provider *client.ProviderInfo Provider *client.ProviderInfo
Model *client.ProviderModel Model *client.ProviderModel
@ -34,7 +33,14 @@ type App struct {
completionDialogOpen bool completionDialogOpen bool
} }
func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, error) { type AppInfo struct {
client.AppInfo
Version string
}
var Info AppInfo
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
err := status.InitService() err := status.InitService()
if err != nil { if err != nil {
slog.Error("Failed to initialize status service", "error", err) slog.Error("Failed to initialize status service", "error", err)
@ -43,6 +49,12 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx) appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
appInfo := appInfoResponse.JSON200 appInfo := appInfoResponse.JSON200
Info = AppInfo{Version: version}
Info.Git = appInfo.Git
Info.Path = appInfo.Path
Info.Time = appInfo.Time
Info.User = appInfo.User
providersResponse, err := httpClient.PostProviderListWithResponse(ctx) providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -70,7 +82,7 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
return nil, fmt.Errorf("no providers found") return nil, fmt.Errorf("no providers found")
} }
appConfigPath := filepath.Join(appInfo.Path.Config, "tui.toml") appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
appConfig, err := config.LoadConfig(appConfigPath) appConfig, err := config.LoadConfig(appConfigPath)
if err != nil { if err != nil {
slog.Info("No TUI config found, using default values", "error", err) slog.Info("No TUI config found, using default values", "error", err)
@ -95,7 +107,6 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
app := &App{ app := &App{
ConfigPath: appConfigPath, ConfigPath: appConfigPath,
Config: appConfig, Config: appConfig,
Info: appInfo,
Client: httpClient, Client: httpClient,
Provider: currentProvider, Provider: currentProvider,
Model: currentModel, Model: currentModel,

View file

@ -5,8 +5,6 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"sync" "sync"
"github.com/sst/opencode/pkg/client"
) )
// MessageCache caches rendered messages to avoid re-rendering // MessageCache caches rendered messages to avoid re-rendering
@ -23,51 +21,27 @@ func NewMessageCache() *MessageCache {
} }
// generateKey creates a unique key for a message based on its content and rendering parameters // generateKey creates a unique key for a message based on its content and rendering parameters
func (c *MessageCache) generateKey(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) string { func (c *MessageCache) GenerateKey(params ...any) string {
// Create a hash of the message content and rendering parameters
h := sha256.New() h := sha256.New()
for _, param := range params {
// Include message ID and role h.Write(fmt.Appendf(nil, ":%v", param))
h.Write(fmt.Appendf(nil, "%s:%s", msg.Id, msg.Role))
// Include timestamp
h.Write(fmt.Appendf(nil, ":%f", msg.Metadata.Time.Created))
// Include width and showToolMessages flag
h.Write(fmt.Appendf(nil, ":%d:%t", width, showToolMessages))
// Include app path for relative path calculations
h.Write([]byte(appInfo.Path.Root))
// Include message parts
for _, part := range msg.Parts {
h.Write(fmt.Appendf(nil, ":%v", part))
} }
// Include tool metadata if present
for toolID, metadata := range msg.Metadata.Tool {
h.Write(fmt.Appendf(nil, ":%s:%v", toolID, metadata))
}
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// Get retrieves a cached rendered message // Get retrieves a cached rendered message
func (c *MessageCache) Get(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) (string, bool) { func (c *MessageCache) Get(key string) (string, bool) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
key := c.generateKey(msg, width, showToolMessages, appInfo)
content, exists := c.cache[key] content, exists := c.cache[key]
return content, exists return content, exists
} }
// Set stores a rendered message in the cache // Set stores a rendered message in the cache
func (c *MessageCache) Set(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo, content string) { func (c *MessageCache) Set(key string, content string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
key := c.generateKey(msg, width, showToolMessages, appInfo)
c.cache[key] = content c.cache[key] = content
} }

View file

@ -1,11 +1,6 @@
package chat package chat
import ( import (
"fmt"
"sort"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@ -16,100 +11,6 @@ type SendMsg struct {
Attachments []app.Attachment Attachments []app.Attachment
} }
func header(app *app.App, width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(app, width),
)
}
func lspsConfigured(width int) string {
// cfg := config.Get()
title := "LSP Servers"
title = ansi.Truncate(title, width, "…")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
lsps := baseStyle.
Width(width).
Foreground(t.Primary()).
Bold(true).
Render(title)
// Get LSP names and sort them for consistent ordering
var lspNames []string
// for name := range cfg.LSP {
// lspNames = append(lspNames, name)
// }
sort.Strings(lspNames)
var lspViews []string
// for _, name := range lspNames {
// lsp := cfg.LSP[name]
// lspName := baseStyle.
// Foreground(t.Text()).
// Render(fmt.Sprintf("• %s", name))
// cmd := lsp.Command
// cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
// lspPath := baseStyle.
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf(" (%s)", cmd))
// lspViews = append(lspViews,
// baseStyle.
// Width(width).
// Render(
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// lspName,
// lspPath,
// ),
// ),
// )
// }
return 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")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
versionText := baseStyle.
Foreground(t.TextMuted()).
Render("v0.0.1") // TODO: get version from server
return baseStyle.
Bold(true).
Width(width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
logo,
" ",
versionText,
),
)
}
func repo(width int) string { func repo(width int) string {
repo := "github.com/sst/opencode" repo := "github.com/sst/opencode"
t := theme.CurrentTheme() t := theme.CurrentTheme()
@ -119,13 +20,3 @@ func repo(width int) string {
Width(width). Width(width).
Render(repo) Render(repo)
} }
func cwd(app *app.App, width int) string {
cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(cwd)
}

View file

@ -10,6 +10,7 @@ import (
"unicode" "unicode"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -23,7 +24,7 @@ import (
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
type editorCmp struct { type editorComponent struct {
width int width int
height int height int
app *app.App app *app.App
@ -33,6 +34,7 @@ type editorCmp struct {
history []string history []string
historyIndex int historyIndex int
currentMessage string currentMessage string
spinner spinner.Model
} }
type EditorKeyMaps struct { type EditorKeyMaps struct {
@ -96,86 +98,19 @@ const (
maxAttachments = 5 maxAttachments = 5
) )
func (m *editorCmp) openEditor(value string) tea.Cmd { func (m *editorComponent) Init() tea.Cmd {
editor := os.Getenv("EDITOR") return tea.Batch(textarea.Blink, m.spinner.Tick)
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
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 {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
} }
func (m *editorCmp) Init() tea.Cmd { func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return textarea.Blink var cmds []tea.Cmd
}
func (m *editorCmp) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case dialog.ThemeChangedMsg: case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea) m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg: case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value() existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue) m.textarea.SetValue(modifiedValue)
return m, nil return m, nil
case dialog.AttachmentAddedMsg: case dialog.AttachmentAddedMsg:
@ -296,47 +231,160 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.send() return m, m.send()
} }
} }
} }
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
return m, cmd cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
} }
func (m *editorCmp) View() string { func (m *editorComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
base := styles.BaseStyle().Render
// Style the prompt with theme colors muted := styles.Muted().Render
style := lipgloss.NewStyle(). promptStyle := lipgloss.NewStyle().
Padding(0, 0, 0, 1). Padding(0, 0, 0, 1).
Bold(true). Bold(true).
Foreground(t.Primary()) Foreground(t.Primary())
prompt := promptStyle.Render(">")
if len(m.attachments) == 0 { textarea := lipgloss.JoinHorizontal(
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) lipgloss.Top,
prompt,
m.textarea.View(),
)
textarea = styles.BaseStyle().
Width(m.width-2).
Border(lipgloss.NormalBorder(), true, true).
BorderForeground(t.Border()).
Render(textarea)
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
if m.app.IsBusy() {
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
} }
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top, model := ""
m.attachmentsContent(), if m.app.Model != nil {
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), model = base(*m.app.Model.Name) + muted(" • /model")
m.textarea.View()), }
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := lipgloss.NewStyle().Width(space).Render("")
info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
info = styles.Padded().Render(info)
content := lipgloss.JoinVertical(
lipgloss.Top,
// m.attachmentsContent(),
textarea,
info,
)
return styles.ForceReplaceBackgroundWithLipgloss(
content,
t.Background(),
) )
} }
func (m *editorCmp) SetSize(width, height int) tea.Cmd { func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width m.width = width
m.height = height m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height) m.textarea.SetHeight(height - 3) // account for info underneath
return nil return nil
} }
func (m *editorCmp) GetSize() (int, int) { func (m *editorComponent) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height() return m.width, m.height
} }
func (m *editorCmp) attachmentsContent() string { func (m *editorComponent) BindingKeys() []key.Binding {
var styledAttachments []string bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func (m *editorComponent) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
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 {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorComponent) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorComponent) attachmentsContent() string {
if len(m.attachments) == 0 {
return ""
}
t := theme.CurrentTheme() t := theme.CurrentTheme()
var styledAttachments []string
attachmentStyles := styles.BaseStyle(). attachmentStyles := styles.BaseStyle().
MarginLeft(1). MarginLeft(1).
Background(t.TextMuted()). Background(t.TextMuted()).
@ -357,20 +405,15 @@ func (m *editorCmp) attachmentsContent() string {
return content return content
} }
func (m *editorCmp) BindingKeys() []key.Binding { func createTextArea(existing *textarea.Model) textarea.Model {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme() t := theme.CurrentTheme()
bgColor := t.Background() bgColor := t.Background()
textColor := t.Text() textColor := t.Text()
textMutedColor := t.TextMuted() textMutedColor := t.TextMuted()
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "It's prompting time..."
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor) ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor) ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor) ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
@ -394,13 +437,16 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta return ta
} }
func NewEditorCmp(app *app.App) tea.Model { func NewEditorComponent(app *app.App) tea.Model {
ta := CreateTextArea(nil) s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
return &editorCmp{ ta := createTextArea(nil)
return &editorComponent{
app: app, app: app,
textarea: ta, textarea: ta,
history: []string{}, history: []string{},
historyIndex: 0, historyIndex: 0,
currentMessage: "", currentMessage: "",
spinner: s,
} }
} }

View file

@ -2,13 +2,18 @@ package chat
import ( import (
"fmt" "fmt"
"log/slog"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"time" "time"
"unicode"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
@ -16,14 +21,12 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
) )
const (
maxResultHeight = 10
)
func toMarkdown(content string, width int) string { func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width) r := styles.GetMarkdownRenderer(width)
content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
rendered, _ := r.Render(content) rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n") lines := strings.Split(rendered, "\n")
if len(lines) > 0 { if len(lines) > 0 {
firstLine := lines[0] firstLine := lines[0]
cleaned := ansi.Strip(firstLine) cleaned := ansi.Strip(firstLine)
@ -40,139 +43,204 @@ func toMarkdown(content string, width int) string {
} }
} }
} }
return strings.TrimSuffix(strings.Join(lines, "\n"), "\n")
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
} }
func renderUserMessage(user string, msg client.MessageInfo, width int) string { type markdownRenderer struct {
align *lipgloss.Position
borderColor *lipgloss.AdaptiveColor
fullWidth bool
paddingTop int
paddingBottom int
}
type markdownRenderingOption func(*markdownRenderer)
func WithFullWidth() markdownRenderingOption {
return func(c *markdownRenderer) {
c.fullWidth = true
}
}
func WithAlign(align lipgloss.Position) markdownRenderingOption {
return func(c *markdownRenderer) {
c.align = &align
}
}
func WithBorderColor(color lipgloss.AdaptiveColor) markdownRenderingOption {
return func(c *markdownRenderer) {
c.borderColor = &color
}
}
func WithPaddingTop(padding int) markdownRenderingOption {
return func(c *markdownRenderer) {
c.paddingTop = padding
}
}
func WithPaddingBottom(padding int) markdownRenderingOption {
return func(c *markdownRenderer) {
c.paddingBottom = padding
}
}
func renderMarkdown(content string, options ...markdownRenderingOption) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
renderer := &markdownRenderer{
fullWidth: false,
}
for _, option := range options {
option(renderer)
}
style := styles.BaseStyle(). style := styles.BaseStyle().
PaddingLeft(1). PaddingTop(1).
BorderLeft(true). PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2).
Background(t.BackgroundSubtle()).
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
// var styledAttachments []string align := lipgloss.Left
// attachmentStyles := baseStyle. if renderer.align != nil {
// MarginLeft(1). align = *renderer.align
// Background(t.TextMuted()). }
// Foreground(t.Text())
// for _, attachment := range msg.BinaryContent() {
// file := filepath.Base(attachment.Path)
// var filename string
// if len(file) > 10 {
// filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
// } else {
// filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
// }
// styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
// }
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM") borderColor := t.BackgroundSubtle()
if renderer.borderColor != nil {
borderColor = *renderer.borderColor
}
switch align {
case lipgloss.Left:
style = style.
BorderLeft(true).
BorderRight(true).
AlignHorizontal(align).
BorderLeftForeground(borderColor).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
case lipgloss.Right:
style = style.
BorderRight(true).
BorderLeft(true).
AlignHorizontal(align).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background()).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background())
}
content = styles.ForceReplaceBackgroundWithLipgloss(content, t.BackgroundSubtle())
if renderer.fullWidth {
style = style.Width(layout.Current.Container.Width - 2)
}
content = style.Render(content)
if renderer.paddingTop > 0 {
content = strings.Repeat("\n", renderer.paddingTop) + content
}
if renderer.paddingBottom > 0 {
content = content + strings.Repeat("\n", renderer.paddingBottom)
}
content = lipgloss.PlaceHorizontal(
layout.Current.Container.Width,
align,
content,
lipgloss.WithWhitespaceBackground(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceBackground(t.Background()),
)
return content
}
func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := 0
switch layout.Current.Size {
case layout.LayoutSizeSmall:
padding = 5
case layout.LayoutSizeNormal:
padding = 10
case layout.LayoutSizeLarge:
padding = 15
}
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] { if time.Now().Format("02 Jan 2006") == timestamp[:11] {
// don't show the date if it's today
timestamp = timestamp[12:] timestamp = timestamp[12:]
} }
info := styles.BaseStyle(). info := styles.BaseStyle().
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", user, timestamp)) Render(fmt.Sprintf("%s (%s)", author, timestamp))
content := "" align := lipgloss.Left
// if len(styledAttachments) > 0 { switch message.Role {
// attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)) case client.User:
// content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...) align = lipgloss.Right
// } else { case client.Assistant:
for _, p := range msg.Parts { align = lipgloss.Left
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
} }
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background()) textWidth := lipgloss.Width(text)
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth)
content = lipgloss.JoinVertical(align, content, info)
switch message.Role {
case client.User:
return renderMarkdown(content,
WithAlign(lipgloss.Right),
WithBorderColor(t.Secondary()),
)
case client.Assistant:
return renderMarkdown(content,
WithAlign(lipgloss.Left),
WithBorderColor(t.Primary()),
)
}
return ""
} }
func renderAssistantMessage( func renderToolInvocation(
msg client.MessageInfo, toolCall client.MessageToolInvocationToolCall,
width int, result *string,
showToolMessages bool, metadata map[string]any,
appInfo client.AppInfo, showResult bool,
) string { ) string {
t := theme.CurrentTheme() ignoredTools := []string{"opencode_todoread"}
style := styles.BaseStyle(). if slices.Contains(ignoredTools, toolCall.ToolName) {
PaddingLeft(1). return ""
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
messages := []string{}
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
modelName := msg.Metadata.Assistant.ModelID
info := styles.BaseStyle().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartReasoning:
// reasoningPart := part.(client.MessagePartReasoning)
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
metadata := map[string]any{}
if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
}
message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
messages = append(messages, message)
}
} }
return strings.Join(messages, "\n\n") padding := 1
} outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
innerWidth := outerWidth - padding - 4 // -4 for the border and padding
func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, appInfo client.AppInfo, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
style := styles.BaseStyle(). style := styles.Muted().
Width(outerWidth).
PaddingLeft(padding).
BorderLeft(true). BorderLeft(true).
PaddingLeft(1). BorderForeground(t.BorderSubtle()).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
toolName := renderToolName(toolCall.ToolName) if toolCall.State == "partial-call" {
style = style.Foreground(t.TextMuted())
return style.Render(renderToolAction(toolCall.ToolName))
}
toolArgs := "" toolArgs := ""
toolArgsMap := make(map[string]any) toolArgsMap := make(map[string]any)
if toolCall.Args != nil { if toolCall.Args != nil {
@ -185,17 +253,20 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
firstKey = key firstKey = key
break break
} }
toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey) toolArgs = renderArgs(&toolArgsMap, firstKey)
} }
} }
title := fmt.Sprintf("%s: %s", toolName, toolArgs) if len(toolArgsMap) == 0 {
finished := result != nil slog.Debug("no args")
body := styles.BaseStyle().Render("In progress...") }
body := ""
finished := result != nil && *result != ""
if finished { if finished {
body = *result body = *result
} }
footer := "" elapsed := ""
if metadata["time"] != nil { if metadata["time"] != nil {
timeMap := metadata["time"].(map[string]any) timeMap := metadata["time"].(map[string]any)
start := timeMap["start"].(float64) start := timeMap["start"].(float64)
@ -206,84 +277,54 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
if durationMs > 1000 { if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second)) roundedDuration = time.Duration(duration.Round(time.Second))
} }
footer = styles.Muted().Render(fmt.Sprintf("%s", roundedDuration)) elapsed = styles.Muted().Render(roundedDuration.String())
} }
title := ""
switch toolCall.ToolName { switch toolCall.ToolName {
case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
body = ""
filename := toolArgsMap["filePath"].(string)
if metadata["preview"] != nil {
body = metadata["preview"].(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "opencode_edit": case "opencode_edit":
filename := toolArgsMap["filePath"].(string) filename := toolArgsMap["filePath"].(string)
filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/") title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
title = fmt.Sprintf("%s: %s", toolName, filename) if metadata["diff"] != nil {
if finished && metadata["diff"] != nil {
patch := metadata["diff"].(string) patch := metadata["diff"].(string)
formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width)) diffWidth := min(layout.Current.Viewport.Width, 120)
formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
body = strings.TrimSpace(formattedDiff) body = strings.TrimSpace(formattedDiff)
return style.Render(lipgloss.JoinVertical(lipgloss.Left, body = lipgloss.Place(
title, layout.Current.Viewport.Width,
lipgloss.Height(body)+2,
lipgloss.Center,
lipgloss.Center,
body, body,
styles.ForceReplaceBackgroundWithLipgloss(footer, t.Background()), lipgloss.WithWhitespaceBackground(t.Background()),
)) )
}
case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, appInfo, "filePath")
title = fmt.Sprintf("%s: %s", toolName, toolArgs)
filename := toolArgsMap["filePath"].(string)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
if finished {
if metadata["preview"] != nil {
body = metadata["preview"].(string)
}
body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
body = toMarkdown(body, width)
} }
case "opencode_write": case "opencode_write":
filename := toolArgsMap["filePath"].(string) filename := toolArgsMap["filePath"].(string)
filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/") title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
title = fmt.Sprintf("%s: %s", toolName, filename)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
content := toolArgsMap["content"].(string) content := toolArgsMap["content"].(string)
body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10)) body = renderFile(filename, content)
body = toMarkdown(body, width)
case "opencode_bash": case "opencode_bash":
if finished && metadata["stdout"] != nil { description := toolArgsMap["description"].(string)
description := toolArgsMap["description"].(string) title = fmt.Sprintf("Shell: %s %s", description, elapsed)
title = fmt.Sprintf("%s: %s", toolName, description) if metadata["stdout"] != nil {
command := toolArgsMap["command"].(string) command := toolArgsMap["command"].(string)
stdout := metadata["stdout"].(string) stdout := metadata["stdout"].(string)
body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, width) body = toMarkdown(body, innerWidth)
} body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
case "opencode_todoread":
title = fmt.Sprintf("%s", toolName)
if finished && metadata["todos"] != nil {
body = ""
todos := metadata["todos"].([]any)
for _, todo := range todos {
t := todo.(map[string]any)
content := t["content"].(string)
switch t["status"].(string) {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] _%s_\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width)
} }
case "opencode_todowrite": case "opencode_todowrite":
title = fmt.Sprintf("%s", toolName) title = fmt.Sprintf("Planning... %s", elapsed)
if finished && metadata["todos"] != nil { if finished && metadata["todos"] != nil {
body = "" body = ""
todos := metadata["todos"].([]any) todos := metadata["todos"].([]any)
@ -299,23 +340,35 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
body += fmt.Sprintf("- [ ] %s\n", content) body += fmt.Sprintf("- [ ] %s\n", content)
} }
} }
body = toMarkdown(body, width) body = toMarkdown(body, innerWidth)
body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
} }
default: default:
body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10)) toolName := renderToolName(toolCall.ToolName)
body = toMarkdown(body, width) title = style.Render(fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed))
// return title
// toolName := renderToolName(toolCall.ToolName)
// title = fmt.Sprintf("%s: %s", toolName, toolArgs)
// body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
// body = toMarkdown(body, contentWidth)
} }
if metadata["error"] != nil && metadata["message"] != nil { if metadata["error"] != nil && metadata["message"] != nil {
body = styles.BaseStyle().Foreground(t.Error()).Render(metadata["message"].(string)) body = styles.BaseStyle().
Width(outerWidth).
Foreground(t.Error()).
Render(metadata["message"].(string))
} }
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, content := style.Render(title)
title, content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
body, content = styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
footer, if showResult && body != "" {
)) content += "\n" + body
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background()) }
return content
// return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
} }
func renderToolName(name string) string { func renderToolName(name string) string {
@ -327,9 +380,9 @@ func renderToolName(name string) string {
case "opencode_webfetch": case "opencode_webfetch":
return "Fetch" return "Fetch"
case "opencode_todoread": case "opencode_todoread":
return "Read TODOs" return "Planning"
case "opencode_todowrite": case "opencode_todowrite":
return "Update TODOs" return "Planning"
default: default:
normalizedName := name normalizedName := name
if strings.HasPrefix(name, "opencode_") { if strings.HasPrefix(name, "opencode_") {
@ -339,6 +392,59 @@ func renderToolName(name string) string {
} }
} }
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func renderFile(filename string, content string, options ...fileRenderingOption) string {
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
// TODO: is this even needed?
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
width := layout.Current.Container.Width - 6
if renderer.height > 0 {
content = truncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width)
// ensure no line is wider than the width
// truncated := []string{}
// for line := range strings.SplitSeq(content, "\n") {
// line = strings.TrimRightFunc(line, unicode.IsSpace)
// // if lipgloss.Width(line) > width-3 {
// line = ansi.Truncate(line, width-3, "")
// // }
// truncated = append(truncated, line)
// }
// content = strings.Join(truncated, "\n")
return renderMarkdown(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
func renderToolAction(name string) string { func renderToolAction(name string) string {
switch name { switch name {
// case agent.AgentToolName: // case agent.AgentToolName:
@ -367,7 +473,7 @@ func renderToolAction(name string) string {
return "Working..." return "Working..."
} }
func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) string { func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 { if args == nil || len(*args) == 0 {
return "" return ""
} }
@ -375,7 +481,7 @@ func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) s
parts := []string{} parts := []string{}
for key, value := range *args { for key, value := range *args {
if key == "filePath" || key == "path" { if key == "filePath" || key == "path" {
value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/") value = relative(value.(string))
} }
if key == titleKey { if key == titleKey {
title = fmt.Sprintf("%s", value) title = fmt.Sprintf("%s", value)
@ -396,3 +502,17 @@ func truncateHeight(content string, height int) string {
} }
return content return content
} }
func relative(path string) string {
return strings.TrimPrefix(path, app.Info.Path.Root+"/")
}
func extension(path string) string {
ext := filepath.Ext(path)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
return ext
}

View file

@ -1,7 +1,7 @@
package chat package chat
import ( import (
"fmt" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -11,21 +11,23 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
) )
type messagesCmp struct { type messagesComponent struct {
app *app.App app *app.App
width, height int width, height int
viewport viewport.Model viewport viewport.Model
spinner spinner.Model spinner spinner.Model
rendering bool rendering bool
attachments viewport.Model attachments viewport.Model
showToolMessages bool showToolResults bool
cache *MessageCache cache *MessageCache
tail bool
} }
type renderFinishedMsg struct{} type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{} type ToggleToolMessagesMsg struct{}
@ -56,44 +58,54 @@ var messageKeys = MessageKeys{
), ),
} }
func (m *messagesCmp) Init() tea.Cmd { func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick) return tea.Batch(m.viewport.Init(), m.spinner.Tick)
} }
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case SendMsg:
m.viewport.GotoBottom()
m.tail = true
return m, nil
case dialog.ThemeChangedMsg: case dialog.ThemeChangedMsg:
m.cache.Clear() m.cache.Clear()
m.renderView() m.renderView()
return m, nil return m, nil
case ToggleToolMessagesMsg: case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages m.showToolResults = !m.showToolResults
m.renderView() m.renderView()
return m, nil return m, nil
case state.SessionSelectedMsg: case state.SessionSelectedMsg:
// Clear cache when switching sessions
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() cmd := m.Reload()
m.viewport.GotoBottom()
return m, cmd return m, cmd
case state.SessionClearedMsg: case state.SessionClearedMsg:
// Clear cache when session is cleared
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() cmd := m.Reload()
return m, cmd return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) || if key.Matches(msg, messageKeys.PageUp) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) { key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) ||
key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg) u, cmd := m.viewport.Update(msg)
m.viewport = u m.viewport = u
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
case renderFinishedMsg: case renderFinishedMsg:
m.rendering = false m.rendering = false
m.viewport.GotoBottom() if m.tail {
m.viewport.GotoBottom()
}
case state.StateUpdatedMsg: case state.StateUpdatedMsg:
m.renderView() m.renderView()
m.viewport.GotoBottom() if m.tail {
m.viewport.GotoBottom()
}
} }
spinner, cmd := m.spinner.Update(msg) spinner, cmd := m.spinner.Update(msg)
@ -102,91 +114,159 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *messagesCmp) renderView() { type blockType int
const (
none blockType = iota
systemTextBlock
userTextBlock
assistantTextBlock
toolInvocationBlock
)
func (m *messagesComponent) renderView() {
if m.width == 0 { if m.width == 0 {
return return
} }
messages := make([]string, 0) blocks := make([]string, 0)
for _, msg := range m.app.Messages { previousBlockType := none
for _, message := range m.app.Messages {
if message.Role == client.System {
continue // ignoring system messages for now
}
var content string var content string
var cached bool var cached bool
switch msg.Role { author := ""
switch message.Role {
case client.User: case client.User:
content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info) author = app.Info.User
if !cached {
content = renderUserMessage(m.app.Info.User, msg, m.width)
m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
}
messages = append(messages, content+"\n")
case client.Assistant: case client.Assistant:
content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info) author = message.Metadata.Assistant.ModelID
if !cached { }
content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content) for _, p := range message.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartStepStart:
// messages = append(messages, "")
case client.MessagePartText:
text := part.(client.MessagePartText)
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(message, text.Text, author)
m.cache.Set(key, content)
}
if previousBlockType != none {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
if message.Role == client.User {
previousBlockType = userTextBlock
} else if message.Role == client.Assistant {
previousBlockType = assistantTextBlock
} else if message.Role == client.System {
previousBlockType = systemTextBlock
}
case client.MessagePartToolInvocation:
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := map[string]any{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
}
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
m.showToolResults,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, never cache
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
}
if previousBlockType != toolInvocationBlock {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
previousBlockType = toolInvocationBlock
} }
messages = append(messages, content+"\n")
} }
} }
m.viewport.SetContent( t := theme.CurrentTheme()
styles.BaseStyle(). centered := []string{}
Render( for _, block := range blocks {
lipgloss.JoinVertical( centered = append(centered, lipgloss.PlaceHorizontal(
lipgloss.Top, m.width,
messages..., lipgloss.Center,
), block,
), lipgloss.WithWhitespaceBackground(t.Background()),
) ))
}
m.viewport.Height = m.height - lipgloss.Height(m.header())
m.viewport.SetContent(strings.Join(centered, "\n"))
} }
func (m *messagesCmp) View() string { func (m *messagesComponent) header() string {
baseStyle := styles.BaseStyle() if m.app.Session.Id == "" {
return ""
if m.rendering {
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
"Loading...",
m.working(),
m.help(),
),
)
} }
if len(m.app.Messages) == 0 { t := theme.CurrentTheme()
content := baseStyle. width := layout.Current.Container.Width
Width(m.width). base := styles.BaseStyle().Render
Height(m.height - 1). muted := styles.Muted().Render
Render( headerLines := []string{}
m.initialScreen(), headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width))
) if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
return baseStyle. } else {
Width(m.width). headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
Render(
lipgloss.JoinVertical(
lipgloss.Top,
content,
"",
m.help(),
),
)
} }
header := strings.Join(headerLines, "\n")
return baseStyle. header = styles.BaseStyle().
Width(m.width). Width(width).
Render( PaddingTop(1).
lipgloss.JoinVertical( BorderBottom(true).
lipgloss.Top, BorderForeground(t.BorderSubtle()).
m.viewport.View(), BorderStyle(lipgloss.NormalBorder()).
m.working(), Background(t.Background()).
m.help(), Render(header)
),
) return styles.ForceReplaceBackgroundWithLipgloss(header, t.Background())
}
func (m *messagesComponent) View() string {
if len(m.app.Messages) == 0 || m.rendering {
return m.home()
}
return lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
m.viewport.View(),
)
} }
// func hasToolsWithoutResponse(messages []message.Message) bool { // func hasToolsWithoutResponse(messages []message.Message) bool {
@ -225,36 +305,7 @@ func (m *messagesCmp) View() string {
// return false // return false
// } // }
func (m *messagesCmp) working() string { func (m *messagesComponent) help() string {
text := ""
if len(m.app.Messages) > 0 {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
task := ""
if m.app.IsBusy() {
task = "Working..."
}
// lastMessage := m.app.Messages[len(m.app.Messages)-1]
// if hasToolsWithoutResponse(m.app.Messages) {
// task = "Waiting for tool response..."
// } else if hasUnfinishedToolCalls(m.app.Messages) {
// task = "Building tool call..."
// } else if !lastMessage.IsFinished() {
// task = "Generating..."
// }
if task != "" {
text += baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
}
}
return text
}
func (m *messagesCmp) help() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
@ -275,11 +326,7 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"), baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
) )
} }
return baseStyle. return baseStyle.
@ -287,20 +334,83 @@ func (m *messagesCmp) help() string {
Render(text) Render(text)
} }
func (m *messagesCmp) initialScreen() string { func (m *messagesComponent) home() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
base := baseStyle.Render
muted := styles.Muted().Render
return baseStyle.Width(m.width).Render( // mark := `
lipgloss.JoinVertical( // ███▀▀█
lipgloss.Top, // ███ █
header(m.app, m.width), // ▀▀▀▀▀▀ `
"", open := `
lspsConfigured(m.width),
),
`
code := `
`
logo := lipgloss.JoinHorizontal(
lipgloss.Top,
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
styles.Muted().Render(open),
styles.BaseStyle().Render(code),
)
cwd := app.Info.Path.Cwd
config := app.Info.Path.Config
commands := [][]string{
{"/help", "show help"},
{"/sessions", "list sessions"},
{"/new", "start a new session"},
{"/model", "switch model"},
{"/share", "share the current session"},
{"/exit", "exit the app"},
}
commandLines := []string{}
for _, command := range commands {
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
}
logoAndVersion := lipgloss.JoinVertical(
lipgloss.Right,
logo,
muted(app.Info.Version),
)
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, base("cwd ")+muted(cwd))
lines = append(lines, base("config ")+muted(config))
lines = append(lines, "")
lines = append(lines, commandLines...)
lines = append(lines, "")
if m.rendering {
lines = append(lines, styles.Muted().Render("Loading session..."))
} else {
lines = append(lines, "")
}
return styles.ForceReplaceBackgroundWithLipgloss(
lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
lipgloss.JoinVertical(
lipgloss.Top,
lines...,
),
)),
t.Background(),
) )
} }
func (m *messagesCmp) SetSize(width, height int) tea.Cmd { func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height { if m.width == width && m.height == height {
return nil return nil
} }
@ -311,18 +421,18 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
m.width = width m.width = width
m.height = height m.height = height
m.viewport.Width = width m.viewport.Width = width
m.viewport.Height = height - 2 m.viewport.Height = height - lipgloss.Height(m.header())
m.attachments.Width = width + 40 m.attachments.Width = width + 40
m.attachments.Height = 3 m.attachments.Height = 3
m.renderView() m.renderView()
return nil return nil
} }
func (m *messagesCmp) GetSize() (int, int) { func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height return m.width, m.height
} }
func (m *messagesCmp) Reload() tea.Cmd { func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true m.rendering = true
return func() tea.Msg { return func() tea.Msg {
m.renderView() m.renderView()
@ -330,7 +440,7 @@ func (m *messagesCmp) Reload() tea.Cmd {
} }
} }
func (m *messagesCmp) BindingKeys() []key.Binding { func (m *messagesComponent) BindingKeys() []key.Binding {
return []key.Binding{ return []key.Binding{
m.viewport.KeyMap.PageDown, m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp, m.viewport.KeyMap.PageUp,
@ -339,7 +449,7 @@ func (m *messagesCmp) BindingKeys() []key.Binding {
} }
} }
func NewMessagesCmp(app *app.App) tea.Model { func NewMessagesComponent(app *app.App) tea.Model {
customSpinner := spinner.Spinner{ customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"}, Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3, FPS: time.Second / 3,
@ -353,12 +463,13 @@ func NewMessagesCmp(app *app.App) tea.Model {
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{ return &messagesComponent{
app: app, app: app,
viewport: vp, viewport: vp,
spinner: s, spinner: s,
attachments: attachments, attachments: attachments,
showToolMessages: true, showToolResults: true,
cache: NewMessageCache(), cache: NewMessageCache(),
tail: true,
} }
} }

View file

@ -1,212 +0,0 @@
package chat
import (
"fmt"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type sidebarCmp struct {
app *app.App
width, height int
modFiles map[string]struct {
additions int
removals int
}
}
func (m *sidebarCmp) Init() tea.Cmd {
// TODO: History service not implemented in API yet
// Initialize the modified files map
m.modFiles = make(map[string]struct {
additions int
removals int
})
return nil
}
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case state.SessionSelectedMsg:
// TODO: History service not implemented in API yet
// ctx := context.Background()
// m.loadModifiedFiles(ctx)
// case pubsub.Event[history.File]:
// TODO: History service not implemented in API yet
// if msg.Payload.SessionID == m.app.CurrentSession.ID {
// // Process the individual file change instead of reloading all files
// ctx := context.Background()
// m.processFileChanges(ctx, msg.Payload)
// }
}
return m, nil
}
func (m *sidebarCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
shareUrl := ""
if m.app.Session.Share != nil {
shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
}
// qrcode := ""
// if m.app.Session.ShareID != nil {
// url := "https://dev.opencode.ai/share?id="
// qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
// }
return baseStyle.
Width(m.width).
PaddingLeft(4).
PaddingRight(1).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.app, m.width),
" ",
m.sessionSection(),
shareUrl,
),
)
}
func (m *sidebarCmp) sessionSection() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
sessionKey := baseStyle.
Foreground(t.Primary()).
Bold(true).
Render("Session")
sessionValue := baseStyle.
Foreground(t.Text()).
Render(fmt.Sprintf(": %s", m.app.Session.Title))
return sessionKey + sessionValue
}
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
stats := ""
if additions > 0 && removals > 0 {
additionsStr := baseStyle.
Foreground(t.Success()).
PaddingLeft(1).
Render(fmt.Sprintf("+%d", additions))
removalsStr := baseStyle.
Foreground(t.Error()).
PaddingLeft(1).
Render(fmt.Sprintf("-%d", removals))
content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
} else if additions > 0 {
additionsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Success()).
Render(fmt.Sprintf("+%d", additions)))
stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
} else if removals > 0 {
removalsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Error()).
Render(fmt.Sprintf("-%d", removals)))
stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
}
filePathStr := baseStyle.Render(filePath)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
filePathStr,
stats,
),
)
}
func (m *sidebarCmp) modifiedFiles() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
modifiedFiles := baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render("Modified Files:")
// If no modified files, show a placeholder message
if m.modFiles == nil || len(m.modFiles) == 0 {
message := "No modified files"
remainingWidth := m.width - lipgloss.Width(message)
if remainingWidth > 0 {
message += strings.Repeat(" ", remainingWidth)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
baseStyle.Foreground(t.TextMuted()).Render(message),
),
)
}
// Sort file paths alphabetically for consistent ordering
var paths []string
for path := range m.modFiles {
paths = append(paths, path)
}
sort.Strings(paths)
// Create views for each file in sorted order
var fileViews []string
for _, path := range paths {
stats := m.modFiles[path]
fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
lipgloss.JoinVertical(
lipgloss.Left,
fileViews...,
),
),
)
}
func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
return nil
}
func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
func NewSidebarCmp(app *app.App) tea.Model {
return &sidebarCmp{
app: app,
}
}

View file

@ -98,16 +98,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// getHelpWidget returns the help widget with current theme colors func logo() string {
func getHelpWidget() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
helpText := "ctrl+? help" mark := styles.Bold().Foreground(t.Primary()).Render("◧ ")
open := styles.Muted().Render("open")
return styles.Padded(). code := styles.BaseStyle().Bold(true).Render("code")
Background(t.TextMuted()). version := styles.Muted().Render(app.Info.Version)
Foreground(t.BackgroundDarker()). return styles.ForceReplaceBackgroundWithLipgloss(
Bold(true). styles.Padded().Render(mark+open+code+" "+version),
Render(helpText) t.BackgroundElement(),
)
} }
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string { func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
@ -132,16 +132,28 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
// Format cost with $ symbol and 2 decimal places // Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost) formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100 percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost) return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
} }
func (m statusCmp) View() string { func (m statusCmp) View() string {
t := theme.CurrentTheme() if m.app.Session.Id == "" {
status := getHelpWidget() return styles.BaseStyle().
Width(m.width).
Height(2).
Render("")
}
t := theme.CurrentTheme()
logo := logo()
cwd := styles.Padded().
Foreground(t.TextMuted()).
Background(t.BackgroundSubtle()).
Render(app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" { if m.app.Session.Id != "" {
tokens := float32(0) tokens := float32(0)
cost := float32(0) cost := float32(0)
@ -157,87 +169,85 @@ func (m statusCmp) View() string {
} }
} }
tokensInfo := styles.Padded(). sessionInfo = styles.Padded().
Background(t.Text()). Background(t.BackgroundElement()).
Foreground(t.BackgroundSecondary()). Foreground(t.TextMuted()).
Render(formatTokensAndCost(tokens, contextWindow, cost)) Render(formatTokensAndCost(tokens, contextWindow, cost))
status += tokensInfo
} }
diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics()) // diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
modelName := m.model() space := max(
statusWidth := max(
0, 0,
m.width- m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
lipgloss.Width(status)-
lipgloss.Width(modelName)-
lipgloss.Width(diagnostics),
) )
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
const minInlineWidth = 30 status := logo + cwd + spacer + sessionInfo
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
// Display the first status message if available // Display the first status message if available
var statusMessage string // var statusMessage string
if len(m.queue) > 0 { // if len(m.queue) > 0 {
sm := m.queue[0] // sm := m.queue[0]
infoStyle := styles.Padded(). // infoStyle := styles.Padded().
Foreground(t.Background()) // Foreground(t.Background())
//
// switch sm.Level {
// case "info":
// infoStyle = infoStyle.Background(t.Info())
// case "warn":
// infoStyle = infoStyle.Background(t.Warning())
// case "error":
// infoStyle = infoStyle.Background(t.Error())
// case "debug":
// infoStyle = infoStyle.Background(t.TextMuted())
// }
//
// // Truncate message if it's longer than available width
// msg := sm.Message
// availWidth := statusWidth - 10
//
// // If we have enough space, show inline
// if availWidth >= minInlineWidth {
// if len(msg) > availWidth && availWidth > 0 {
// msg = msg[:availWidth] + "..."
// }
// status += infoStyle.Width(statusWidth).Render(msg)
// } else {
// // Otherwise, prepare a full-width message to show above
// if len(msg) > m.width-10 && m.width > 10 {
// msg = msg[:m.width-10] + "..."
// }
// statusMessage = infoStyle.Width(m.width).Render(msg)
//
// // Add empty space in the status bar
// status += styles.Padded().
// Foreground(t.Text()).
// Background(t.BackgroundSubtle()).
// Width(statusWidth).
// Render("")
// }
// } else {
// status += styles.Padded().
// Foreground(t.Text()).
// Background(t.BackgroundSubtle()).
// Width(statusWidth).
// Render("")
// }
switch sm.Level { // status += diagnostics
case "info": // status += modelName
infoStyle = infoStyle.Background(t.Info())
case "warn":
infoStyle = infoStyle.Background(t.Warning())
case "error":
infoStyle = infoStyle.Background(t.Error())
case "debug":
infoStyle = infoStyle.Background(t.TextMuted())
}
// Truncate message if it's longer than available width
msg := sm.Message
availWidth := statusWidth - 10
// If we have enough space, show inline
if availWidth >= minInlineWidth {
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
status += infoStyle.Width(statusWidth).Render(msg)
} else {
// Otherwise, prepare a full-width message to show above
if len(msg) > m.width-10 && m.width > 10 {
msg = msg[:m.width-10] + "..."
}
statusMessage = infoStyle.Width(m.width).Render(msg)
// Add empty space in the status bar
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
status += diagnostics
status += modelName
// If we have a separate status message, prepend it // If we have a separate status message, prepend it
if statusMessage != "" { // if statusMessage != "" {
return statusMessage + "\n" + status // return statusMessage + "\n" + status
} else { // } else {
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("") // blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status // return blank + "\n" + status
} // }
} }
func (m *statusCmp) projectDiagnostics() string { func (m *statusCmp) projectDiagnostics() string {
@ -281,7 +291,7 @@ func (m *statusCmp) projectDiagnostics() string {
// } // }
return styles.ForceReplaceBackgroundWithLipgloss( return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render("No diagnostics"), styles.Padded().Render("No diagnostics"),
t.BackgroundDarker(), t.BackgroundElement(),
) )
// if len(errorDiagnostics) == 0 && // if len(errorDiagnostics) == 0 &&

View file

@ -22,7 +22,7 @@ const (
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory // LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands(app *app.App) ([]Command, error) { func LoadCustomCommands() ([]Command, error) {
var commands []Command var commands []Command
homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands") homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")

View file

@ -404,10 +404,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
<entry type="TextWhitespace" style="%s"/> <entry type="TextWhitespace" style="%s"/>
</style> </style>
`, `,
getColor(t.Background()), // Background getColor(t.BackgroundSubtle()), // Background
getColor(t.Text()), // Text getColor(t.Text()), // Text
getColor(t.Text()), // Other getColor(t.Text()), // Other
getColor(t.Error()), // Error getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant getColor(t.SyntaxKeyword()), // KeywordConstant
@ -531,8 +531,7 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg()) removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg()) addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg()) contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber()) lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
return return
} }
@ -581,7 +580,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the appropriate color based on terminal background // Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg)) bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background())) fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
for i := 0; i < len(content); { for i := 0; i < len(content); {
// Check if we're at an ANSI sequence // Check if we're at an ANSI sequence
@ -794,24 +793,24 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
} }
// FormatDiff creates a side-by-side formatted view of a diff // FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) { func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
t := theme.CurrentTheme() // t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText) diffResult, err := ParseUnifiedDiff(diffText)
if err != nil { if err != nil {
return "", err return "", err
} }
var sb strings.Builder var sb strings.Builder
config := NewSideBySideConfig(opts...) // config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks { for _, h := range diffResult.Hunks {
sb.WriteString( // sb.WriteString(
lipgloss.NewStyle(). // lipgloss.NewStyle().
Background(t.DiffHunkHeader()). // Background(t.DiffHunkHeader()).
Foreground(t.Background()). // Foreground(t.Background()).
Width(config.TotalWidth). // Width(config.TotalWidth).
Render(h.Header) + "\n", // Render(h.Header) + "\n",
) // )
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...)) sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
} }
return sb.String(), nil return sb.String(), nil

View file

@ -1,127 +0,0 @@
package spinner
import (
"context"
"fmt"
"os"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
type Spinner struct {
model spinner.Model
done chan struct{}
prog *tea.Program
ctx context.Context
cancel context.CancelFunc
}
// spinnerModel is the tea.Model for the spinner
type spinnerModel struct {
spinner spinner.Model
message string
quitting bool
}
func (m spinnerModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
m.quitting = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case quitMsg:
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
}
func (m spinnerModel) View() string {
if m.quitting {
return ""
}
return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
}
// quitMsg is sent when we want to quit the spinner
type quitMsg struct{}
// NewSpinner creates a new spinner with the given message
func NewSpinner(message string) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(s.Style.GetForeground())
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// NewThemedSpinner creates a new spinner with the given message and color
func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(color)
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
go func() {
defer close(s.done)
go func() {
<-s.ctx.Done()
s.prog.Send(quitMsg{})
}()
_, err := s.prog.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
}
}()
}
// Stop ends the spinner animation
func (s *Spinner) Stop() {
s.cancel()
<-s.done
}

View file

@ -1,24 +0,0 @@
package spinner
import (
"testing"
"time"
)
func TestSpinner(t *testing.T) {
t.Parallel()
// Create a spinner
s := NewSpinner("Test spinner")
// Start the spinner
s.Start()
// Wait a bit to let it run
time.Sleep(100 * time.Millisecond)
// Stop the spinner
s.Stop()
// If we got here without panicking, the test passes
}

View file

@ -13,6 +13,8 @@ type Container interface {
Bindings Bindings
Focus() Focus()
Blur() Blur()
MaxWidth() int
Alignment() lipgloss.Position
} }
type container struct { type container struct {
@ -32,6 +34,9 @@ type container struct {
borderLeft bool borderLeft bool
borderStyle lipgloss.Border borderStyle lipgloss.Border
maxWidth int
align lipgloss.Position
focused bool focused bool
} }
@ -51,6 +56,11 @@ func (c *container) View() string {
width := c.width width := c.width
height := c.height height := c.height
// Apply max width constraint if set
if c.maxWidth > 0 && width > c.maxWidth {
width = c.maxWidth
}
style = style.Background(t.Background()) style = style.Background(t.Background())
// Apply border if any side is enabled // Apply border if any side is enabled
@ -74,7 +84,7 @@ func (c *container) View() string {
if c.focused { if c.focused {
style = style.BorderBackground(t.Background()).BorderForeground(t.Primary()) style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
} else { } else {
style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal()) style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
} }
} }
style = style. style = style.
@ -92,6 +102,12 @@ func (c *container) SetSize(width, height int) tea.Cmd {
c.width = width c.width = width
c.height = height c.height = height
// Apply max width constraint if set
effectiveWidth := width
if c.maxWidth > 0 && width > c.maxWidth {
effectiveWidth = c.maxWidth
}
// If the content implements Sizeable, adjust its size to account for padding and borders // If the content implements Sizeable, adjust its size to account for padding and borders
if sizeable, ok := c.content.(Sizeable); ok { if sizeable, ok := c.content.(Sizeable); ok {
// Calculate horizontal space taken by padding and borders // Calculate horizontal space taken by padding and borders
@ -113,7 +129,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
} }
// Set content size with adjusted dimensions // Set content size with adjusted dimensions
contentWidth := max(0, width-horizontalSpace) contentWidth := max(0, effectiveWidth-horizontalSpace)
contentHeight := max(0, height-verticalSpace) contentHeight := max(0, height-verticalSpace)
return sizeable.SetSize(contentWidth, contentHeight) return sizeable.SetSize(contentWidth, contentHeight)
} }
@ -124,6 +140,14 @@ func (c *container) GetSize() (int, int) {
return c.width, c.height return c.width, c.height
} }
func (c *container) MaxWidth() int {
return c.maxWidth
}
func (c *container) Alignment() lipgloss.Position {
return c.align
}
func (c *container) BindingKeys() []key.Binding { func (c *container) BindingKeys() []key.Binding {
if b, ok := c.content.(Bindings); ok { if b, ok := c.content.(Bindings); ok {
return b.BindingKeys() return b.BindingKeys()
@ -228,3 +252,27 @@ func WithThickBorder() ContainerOption {
func WithDoubleBorder() ContainerOption { func WithDoubleBorder() ContainerOption {
return WithBorderStyle(lipgloss.DoubleBorder()) return WithBorderStyle(lipgloss.DoubleBorder())
} }
func WithMaxWidth(maxWidth int) ContainerOption {
return func(c *container) {
c.maxWidth = maxWidth
}
}
func WithAlign(align lipgloss.Position) ContainerOption {
return func(c *container) {
c.align = align
}
}
func WithAlignLeft() ContainerOption {
return WithAlign(lipgloss.Left)
}
func WithAlignCenter() ContainerOption {
return WithAlign(lipgloss.Center)
}
func WithAlignRight() ContainerOption {
return WithAlign(lipgloss.Right)
}

View file

@ -0,0 +1,248 @@
package layout
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/theme"
)
type FlexDirection int
const (
FlexDirectionHorizontal FlexDirection = iota
FlexDirectionVertical
)
type FlexPaneSize struct {
Fixed bool
Size int
}
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
func FlexPaneSizeFixed(size int) FlexPaneSize {
return FlexPaneSize{Fixed: true, Size: size}
}
type FlexLayout interface {
tea.Model
Sizeable
Bindings
SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd
}
type flexLayout struct {
width int
height int
direction FlexDirection
panes []Container
sizes []FlexPaneSize
}
type FlexLayoutOption func(*flexLayout)
func (f *flexLayout) Init() tea.Cmd {
var cmds []tea.Cmd
for _, pane := range f.panes {
if pane != nil {
cmds = append(cmds, pane.Init())
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return f, f.SetSize(msg.Width, msg.Height)
}
for i, pane := range f.panes {
if pane != nil {
u, cmd := pane.Update(msg)
f.panes[i] = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return f, tea.Batch(cmds...)
}
func (f *flexLayout) View() string {
t := theme.CurrentTheme()
if len(f.panes) == 0 {
return ""
}
views := make([]string, 0, len(f.panes))
for i, pane := range f.panes {
if pane == nil {
continue
}
var paneWidth, paneHeight int
if f.direction == FlexDirectionHorizontal {
paneWidth, paneHeight = f.calculatePaneSize(i)
view := lipgloss.PlaceHorizontal(
paneWidth,
pane.Alignment(),
pane.View(),
lipgloss.WithWhitespaceBackground(t.Background()),
)
views = append(views, view)
} else {
paneWidth, paneHeight = f.calculatePaneSize(i)
view := lipgloss.Place(
f.width,
paneHeight,
lipgloss.Center,
pane.Alignment(),
pane.View(),
lipgloss.WithWhitespaceBackground(t.Background()),
)
views = append(views, view)
}
}
if f.direction == FlexDirectionHorizontal {
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
}
return lipgloss.JoinVertical(lipgloss.Center, views...)
}
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
if index >= len(f.panes) {
return 0, 0
}
totalFixed := 0
flexCount := 0
for i, pane := range f.panes {
if pane == nil {
continue
}
if i < len(f.sizes) && f.sizes[i].Fixed {
if f.direction == FlexDirectionHorizontal {
totalFixed += f.sizes[i].Size
} else {
totalFixed += f.sizes[i].Size
}
} else {
flexCount++
}
}
if f.direction == FlexDirectionHorizontal {
height = f.height
if index < len(f.sizes) && f.sizes[index].Fixed {
width = f.sizes[index].Size
} else if flexCount > 0 {
remainingSpace := f.width - totalFixed
width = remainingSpace / flexCount
}
} else {
width = f.width
if index < len(f.sizes) && f.sizes[index].Fixed {
height = f.sizes[index].Size
} else if flexCount > 0 {
remainingSpace := f.height - totalFixed
height = remainingSpace / flexCount
}
}
return width, height
}
func (f *flexLayout) SetSize(width, height int) tea.Cmd {
f.width = width
f.height = height
var cmds []tea.Cmd
for i, pane := range f.panes {
if pane != nil {
paneWidth, paneHeight := f.calculatePaneSize(i)
cmd := pane.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) GetSize() (int, int) {
return f.width, f.height
}
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
f.panes = panes
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
f.sizes = sizes
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
f.direction = direction
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) BindingKeys() []key.Binding {
keys := []key.Binding{}
for _, pane := range f.panes {
if pane != nil {
if b, ok := pane.(Bindings); ok {
keys = append(keys, b.BindingKeys()...)
}
}
}
return keys
}
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{
direction: FlexDirectionHorizontal,
panes: []Container{},
sizes: []FlexPaneSize{},
}
for _, option := range options {
option(layout)
}
return layout
}
func WithDirection(direction FlexDirection) FlexLayoutOption {
return func(f *flexLayout) {
f.direction = direction
}
}
func WithPanes(panes ...Container) FlexLayoutOption {
return func(f *flexLayout) {
f.panes = panes
}
}
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
return func(f *flexLayout) {
f.sizes = sizes
}
}

View file

@ -7,6 +7,35 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
var Current *LayoutInfo
func init() {
Current = &LayoutInfo{
Size: LayoutSizeNormal,
Viewport: Dimensions{Width: 80, Height: 25},
Container: Dimensions{Width: 80, Height: 25},
}
}
type LayoutSize string
const (
LayoutSizeSmall LayoutSize = "small"
LayoutSizeNormal LayoutSize = "normal"
LayoutSizeLarge LayoutSize = "large"
)
type Dimensions struct {
Width int
Height int
}
type LayoutInfo struct {
Size LayoutSize
Viewport Dimensions
Container Dimensions
}
type Focusable interface { type Focusable interface {
Focus() tea.Cmd Focus() tea.Cmd
Blur() tea.Cmd Blur() tea.Cmd

View file

@ -17,18 +17,15 @@ import (
// https://github.com/charmbracelet/lipgloss/pull/102 // https://github.com/charmbracelet/lipgloss/pull/102
// as well as the lipgloss library, with some modification for what I needed. // as well as the lipgloss library, with some modification for what I needed.
// Split a string into lines, additionally returning the size of the widest // Split a string into lines, additionally returning the size of the widest line.
// line.
func getLines(s string) (lines []string, widest int) { func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n") lines = strings.Split(s, "\n")
for _, l := range lines { for _, l := range lines {
w := ansi.PrintableRuneWidth(l) w := ansi.PrintableRuneWidth(l)
if widest < w { if widest < w {
widest = w widest = w
} }
} }
return lines, widest return lines, widest
} }
@ -49,7 +46,7 @@ func PlaceOverlay(
var shadowbg string = "" var shadowbg string = ""
shadowchar := lipgloss.NewStyle(). shadowchar := lipgloss.NewStyle().
Background(t.BackgroundDarker()). Background(t.BackgroundElement()).
Foreground(t.Background()). Foreground(t.Background()).
Render("░") Render("░")
bgchar := baseStyle.Render(" ") bgchar := baseStyle.Render(" ")

View file

@ -1,283 +0,0 @@
package layout
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/theme"
)
type SplitPaneLayout interface {
tea.Model
Sizeable
Bindings
SetLeftPanel(panel Container) tea.Cmd
SetRightPanel(panel Container) tea.Cmd
SetBottomPanel(panel Container) tea.Cmd
ClearLeftPanel() tea.Cmd
ClearRightPanel() tea.Cmd
ClearBottomPanel() tea.Cmd
}
type splitPaneLayout struct {
width int
height int
ratio float64
verticalRatio float64
rightPanel Container
leftPanel Container
bottomPanel Container
}
type SplitPaneOption func(*splitPaneLayout)
func (s *splitPaneLayout) Init() tea.Cmd {
var cmds []tea.Cmd
if s.leftPanel != nil {
cmds = append(cmds, s.leftPanel.Init())
}
if s.rightPanel != nil {
cmds = append(cmds, s.rightPanel.Init())
}
if s.bottomPanel != nil {
cmds = append(cmds, s.bottomPanel.Init())
}
return tea.Batch(cmds...)
}
func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return s, s.SetSize(msg.Width, msg.Height)
}
if s.rightPanel != nil {
u, cmd := s.rightPanel.Update(msg)
s.rightPanel = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if s.leftPanel != nil {
u, cmd := s.leftPanel.Update(msg)
s.leftPanel = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if s.bottomPanel != nil {
u, cmd := s.bottomPanel.Update(msg)
s.bottomPanel = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return s, tea.Batch(cmds...)
}
func (s *splitPaneLayout) View() string {
var topSection string
if s.leftPanel != nil && s.rightPanel != nil {
leftView := s.leftPanel.View()
rightView := s.rightPanel.View()
topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
} else if s.leftPanel != nil {
topSection = s.leftPanel.View()
} else if s.rightPanel != nil {
topSection = s.rightPanel.View()
} else {
topSection = ""
}
var finalView string
if s.bottomPanel != nil && topSection != "" {
bottomView := s.bottomPanel.View()
finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
} else if s.bottomPanel != nil {
finalView = s.bottomPanel.View()
} else {
finalView = topSection
}
if finalView != "" {
t := theme.CurrentTheme()
style := lipgloss.NewStyle().
Width(s.width).
Height(s.height).
Background(t.Background())
return style.Render(finalView)
}
return finalView
}
func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
s.width = width
s.height = height
var topHeight, bottomHeight int
if s.bottomPanel != nil {
topHeight = int(float64(height) * s.verticalRatio)
bottomHeight = height - topHeight
} else {
topHeight = height
bottomHeight = 0
}
var leftWidth, rightWidth int
if s.leftPanel != nil && s.rightPanel != nil {
leftWidth = int(float64(width) * s.ratio)
rightWidth = width - leftWidth
} else if s.leftPanel != nil {
leftWidth = width
rightWidth = 0
} else if s.rightPanel != nil {
leftWidth = 0
rightWidth = width
}
var cmds []tea.Cmd
if s.leftPanel != nil {
cmd := s.leftPanel.SetSize(leftWidth, topHeight)
cmds = append(cmds, cmd)
}
if s.rightPanel != nil {
cmd := s.rightPanel.SetSize(rightWidth, topHeight)
cmds = append(cmds, cmd)
}
if s.bottomPanel != nil {
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) tea.Cmd {
s.leftPanel = panel
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
s.rightPanel = panel
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
s.bottomPanel = panel
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
s.leftPanel = nil
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
s.rightPanel = nil
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
s.bottomPanel = nil
if s.width > 0 && s.height > 0 {
return s.SetSize(s.width, s.height)
}
return nil
}
func (s *splitPaneLayout) BindingKeys() []key.Binding {
keys := []key.Binding{}
if s.leftPanel != nil {
if b, ok := s.leftPanel.(Bindings); ok {
keys = append(keys, b.BindingKeys()...)
}
}
if s.rightPanel != nil {
if b, ok := s.rightPanel.(Bindings); ok {
keys = append(keys, b.BindingKeys()...)
}
}
if s.bottomPanel != nil {
if b, ok := s.bottomPanel.(Bindings); ok {
keys = append(keys, b.BindingKeys()...)
}
}
return keys
}
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
layout := &splitPaneLayout{
ratio: 0.7,
verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
}
for _, option := range options {
option(layout)
}
return layout
}
func WithLeftPanel(panel Container) SplitPaneOption {
return func(s *splitPaneLayout) {
s.leftPanel = panel
}
}
func WithRightPanel(panel Container) SplitPaneOption {
return func(s *splitPaneLayout) {
s.rightPanel = panel
}
}
func WithRatio(ratio float64) SplitPaneOption {
return func(s *splitPaneLayout) {
s.ratio = ratio
}
}
func WithBottomPanel(panel Container) SplitPaneOption {
return func(s *splitPaneLayout) {
s.bottomPanel = panel
}
}
func WithVerticalRatio(ratio float64) SplitPaneOption {
return func(s *splitPaneLayout) {
s.verticalRatio = ratio
}
}

View file

@ -24,7 +24,7 @@ type chatPage struct {
app *app.App app *app.App
editor layout.Container editor layout.Container
messages layout.Container messages layout.Container
layout layout.SplitPaneLayout layout layout.FlexLayout
completionDialog dialog.CompletionDialog completionDialog dialog.CompletionDialog
showCompletionDialog bool showCompletionDialog bool
} }
@ -96,12 +96,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil { if cmd != nil {
return p, cmd return p, cmd
} }
case state.SessionSelectedMsg:
cmd := p.setSidebar()
cmds = append(cmds, cmd)
case state.SessionClearedMsg:
cmd := p.setSidebar()
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg: case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false p.showCompletionDialog = false
@ -116,7 +110,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.app.Session = &client.SessionInfo{} p.app.Session = &client.SessionInfo{}
p.app.Messages = []client.MessageInfo{} p.app.Messages = []client.MessageInfo{}
return p, tea.Batch( return p, tea.Batch(
p.clearSidebar(),
util.CmdHandler(state.SessionClearedMsg{}), util.CmdHandler(state.SessionClearedMsg{}),
) )
case key.Matches(msg, keyMap.Cancel): case key.Matches(msg, keyMap.Cancel):
@ -145,30 +138,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := p.layout.Update(msg) u, cmd := p.layout.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
p.layout = u.(layout.SplitPaneLayout) p.layout = u.(layout.FlexLayout)
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
} }
func (p *chatPage) setSidebar() tea.Cmd {
sidebarContainer := layout.NewContainer(
chat.NewSidebarCmp(p.app),
layout.WithPadding(1, 1, 1, 1),
)
return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
}
func (p *chatPage) clearSidebar() tea.Cmd {
return p.layout.ClearRightPanel()
}
func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd { func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
cmd := p.app.SendChatMessage(context.Background(), text, attachments) cmd := p.app.SendChatMessage(context.Background(), text, attachments)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmd = p.setSidebar()
if cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
@ -183,6 +160,7 @@ func (p *chatPage) GetSize() (int, int) {
func (p *chatPage) View() string { func (p *chatPage) View() string {
layoutView := p.layout.View() layoutView := p.layout.View()
// TODO: Fix this with our new layout
if p.showCompletionDialog { if p.showCompletionDialog {
_, layoutHeight := p.layout.GetSize() _, layoutHeight := p.layout.GetSize()
editorWidth, editorHeight := p.editor.GetSize() editorWidth, editorHeight := p.editor.GetSize()
@ -213,21 +191,25 @@ func NewChatPage(app *app.App) tea.Model {
cg := completions.NewFileAndFolderContextGroup() cg := completions.NewFileAndFolderContextGroup()
completionDialog := dialog.NewCompletionDialogCmp(cg) completionDialog := dialog.NewCompletionDialogCmp(cg)
messagesContainer := layout.NewContainer( messagesContainer := layout.NewContainer(
chat.NewMessagesCmp(app), chat.NewMessagesComponent(app),
layout.WithPadding(1, 1, 0, 1),
) )
editorContainer := layout.NewContainer( editorContainer := layout.NewContainer(
chat.NewEditorCmp(app), chat.NewEditorComponent(app),
layout.WithBorder(true, false, false, false), layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
) )
return &chatPage{ return &chatPage{
app: app, app: app,
editor: editorContainer, editor: editorContainer,
messages: messagesContainer, messages: messagesContainer,
completionDialog: completionDialog, completionDialog: completionDialog,
layout: layout.NewSplitPane( layout: layout.NewFlexLayout(
layout.WithLeftPanel(messagesContainer), layout.WithPanes(messagesContainer, editorContainer),
layout.WithBottomPanel(editorContainer), layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(6),
),
), ),
} }
} }

View file

@ -13,23 +13,33 @@ func BaseStyle() lipgloss.Style {
Foreground(t.Text()) Foreground(t.Text())
} }
func Panel() lipgloss.Style {
t := theme.CurrentTheme()
return lipgloss.NewStyle().
Background(t.BackgroundSubtle()).
Border(lipgloss.NormalBorder(), true, false, true, false).
BorderForeground(t.BorderSubtle()).
Foreground(t.Text())
}
// Regular returns a basic unstyled lipgloss.Style // Regular returns a basic unstyled lipgloss.Style
func Regular() lipgloss.Style { func Regular() lipgloss.Style {
return lipgloss.NewStyle() return lipgloss.NewStyle()
} }
func Muted() lipgloss.Style { func Muted() lipgloss.Style {
return lipgloss.NewStyle().Foreground(theme.CurrentTheme().TextMuted()) t := theme.CurrentTheme()
return lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted())
} }
// Bold returns a bold style // Bold returns a bold style
func Bold() lipgloss.Style { func Bold() lipgloss.Style {
return Regular().Bold(true) return BaseStyle().Bold(true)
} }
// Padded returns a style with horizontal padding // Padded returns a style with horizontal padding
func Padded() lipgloss.Style { func Padded() lipgloss.Style {
return Regular().Padding(0, 1) return BaseStyle().Padding(0, 1)
} }
// Border returns a style with a normal border // Border returns a style with a normal border
@ -37,7 +47,7 @@ func Border() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return Regular(). return Regular().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderNormal()) BorderForeground(t.Border())
} }
// ThickBorder returns a style with a thick border // ThickBorder returns a style with a thick border
@ -45,7 +55,7 @@ func ThickBorder() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return Regular(). return Regular().
Border(lipgloss.ThickBorder()). Border(lipgloss.ThickBorder()).
BorderForeground(t.BorderNormal()) BorderForeground(t.Border())
} }
// DoubleBorder returns a style with a double border // DoubleBorder returns a style with a double border
@ -53,7 +63,7 @@ func DoubleBorder() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return Regular(). return Regular().
Border(lipgloss.DoubleBorder()). Border(lipgloss.DoubleBorder()).
BorderForeground(t.BorderNormal()) BorderForeground(t.Border())
} }
// FocusedBorder returns a style with a border using the focused border color // FocusedBorder returns a style with a border using the focused border color
@ -61,7 +71,7 @@ func FocusedBorder() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return Regular(). return Regular().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderFocused()) BorderForeground(t.BorderActive())
} }
// DimBorder returns a style with a border using the dim border color // DimBorder returns a style with a border using the dim border color
@ -69,7 +79,7 @@ func DimBorder() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return Regular(). return Regular().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderDim()) BorderForeground(t.BorderSubtle())
} }
// PrimaryColor returns the primary color from the current theme // PrimaryColor returns the primary color from the current theme
@ -117,37 +127,32 @@ func TextMutedColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().TextMuted() return theme.CurrentTheme().TextMuted()
} }
// TextEmphasizedColor returns the emphasized text color from the current theme
func TextEmphasizedColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().TextEmphasized()
}
// BackgroundColor returns the background color from the current theme // BackgroundColor returns the background color from the current theme
func BackgroundColor() lipgloss.AdaptiveColor { func BackgroundColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Background() return theme.CurrentTheme().Background()
} }
// BackgroundSecondaryColor returns the secondary background color from the current theme // BackgroundSubtleColor returns the subtle background color from the current theme
func BackgroundSecondaryColor() lipgloss.AdaptiveColor { func BackgroundSubtleColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BackgroundSecondary() return theme.CurrentTheme().BackgroundSubtle()
} }
// BackgroundDarkerColor returns the darker background color from the current theme // BackgroundElementColor returns the darker background color from the current theme
func BackgroundDarkerColor() lipgloss.AdaptiveColor { func BackgroundElementColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BackgroundDarker() return theme.CurrentTheme().BackgroundElement()
} }
// BorderNormalColor returns the normal border color from the current theme // BorderColor returns the border color from the current theme
func BorderNormalColor() lipgloss.AdaptiveColor { func BorderColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderNormal() return theme.CurrentTheme().Border()
} }
// BorderFocusedColor returns the focused border color from the current theme // BorderActiveColor returns the active border color from the current theme
func BorderFocusedColor() lipgloss.AdaptiveColor { func BorderActiveColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderFocused() return theme.CurrentTheme().BorderActive()
} }
// BorderDimColor returns the dim border color from the current theme // BorderSubtleColor returns the subtle border color from the current theme
func BorderDimColor() lipgloss.AdaptiveColor { func BorderSubtleColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderDim() return theme.CurrentTheme().BorderSubtle()
} }

View file

@ -92,35 +92,31 @@ func NewAyuDarkTheme() *AyuDarkTheme {
Dark: darkComment, Dark: darkComment,
Light: lightComment, Light: lightComment,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkBackground,
Light: lightBackground, Light: lightBackground,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkCurrentLine,
Light: lightCurrentLine, Light: lightCurrentLine,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#0b0e14", // Darker than background Dark: "#0b0e14", // Darker than background
Light: "#ffffff", // Lighter than background Light: "#ffffff", // Lighter than background
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkBorder,
Light: lightBorder, Light: lightBorder,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkBlue, Dark: darkBlue,
Light: lightBlue, Light: lightBlue,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkSelection,
Light: lightSelection, Light: lightSelection,
} }

View file

@ -60,35 +60,31 @@ func NewCatppuccinTheme() *CatppuccinTheme {
Dark: mocha.Subtext0().Hex, Dark: mocha.Subtext0().Hex,
Light: latte.Subtext0().Hex, Light: latte.Subtext0().Hex,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: mocha.Lavender().Hex,
Light: latte.Lavender().Hex,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: "#212121", // From existing styles Dark: "#212121", // From existing styles
Light: "#EEEEEE", // Light equivalent Light: "#EEEEEE", // Light equivalent
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: "#2c2c2c", // From existing styles Dark: "#2c2c2c", // From existing styles
Light: "#E0E0E0", // Light equivalent Light: "#E0E0E0", // Light equivalent
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#181818", // From existing styles Dark: "#181818", // From existing styles
Light: "#F5F5F5", // Light equivalent Light: "#F5F5F5", // Light equivalent
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: "#4b4c5c", // From existing styles Dark: "#4b4c5c", // From existing styles
Light: "#BDBDBD", // Light equivalent Light: "#BDBDBD", // Light equivalent
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex, Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex, Light: latte.Blue().Hex,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: mocha.Surface0().Hex, Dark: mocha.Surface0().Hex,
Light: latte.Surface0().Hex, Light: latte.Surface0().Hex,
} }

View file

@ -86,35 +86,31 @@ func NewDraculaTheme() *DraculaTheme {
Dark: darkComment, Dark: darkComment,
Light: lightComment, Light: lightComment,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkBackground,
Light: lightBackground, Light: lightBackground,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkCurrentLine,
Light: lightCurrentLine, Light: lightCurrentLine,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: "#21222c", // Slightly darker than background Dark: "#21222c", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background Light: "#ffffff", // Slightly lighter than background
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkBorder,
Light: lightBorder, Light: lightBorder,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkPurple, Dark: darkPurple,
Light: lightPurple, Light: lightPurple,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkSelection,
Light: lightSelection, Light: lightSelection,
} }
@ -133,8 +129,8 @@ func NewDraculaTheme() *DraculaTheme {
Light: lightComment, Light: lightComment,
} }
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: darkPurple, Dark: darkCurrentLine,
Light: lightPurple, Light: lightCurrentLine,
} }
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#50fa7b", Dark: "#50fa7b",

View file

@ -94,35 +94,31 @@ func NewFlexokiTheme() *FlexokiTheme {
Dark: flexokiBase700, Dark: flexokiBase700,
Light: flexokiBase500, Light: flexokiBase500,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400,
Light: flexokiYellow600,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: flexokiBlack, Dark: flexokiBlack,
Light: flexokiPaper, Light: flexokiPaper,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: flexokiBase950, Dark: flexokiBase950,
Light: flexokiBase50, Light: flexokiBase50,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900, Dark: flexokiBase900,
Light: flexokiBase100, Light: flexokiBase100,
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900, Dark: flexokiBase900,
Light: flexokiBase100, Light: flexokiBase100,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400, Dark: flexokiBlue400,
Light: flexokiBlue600, Light: flexokiBlue600,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: flexokiBase850, Dark: flexokiBase850,
Light: flexokiBase150, Light: flexokiBase150,
} }

View file

@ -114,35 +114,31 @@ func NewGruvboxTheme() *GruvboxTheme {
Dark: gruvboxDarkFg4, Dark: gruvboxDarkFg4,
Light: gruvboxLightFg4, Light: gruvboxLightFg4,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0, Dark: gruvboxDarkBg0,
Light: gruvboxLightBg0, Light: gruvboxLightBg0,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1, Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1, Light: gruvboxLightBg1,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0Soft, Dark: gruvboxDarkBg0Soft,
Light: gruvboxLightBg0Soft, Light: gruvboxLightBg0Soft,
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg2, Dark: gruvboxDarkBg2,
Light: gruvboxLightBg2, Light: gruvboxLightBg2,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright, Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright, Light: gruvboxLightBlueBright,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1, Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1, Light: gruvboxLightBg1,
} }

View file

@ -157,20 +157,18 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
theme.TextColor = adaptiveColor theme.TextColor = adaptiveColor
case "textmuted": case "textmuted":
theme.TextMutedColor = adaptiveColor theme.TextMutedColor = adaptiveColor
case "textemphasized":
theme.TextEmphasizedColor = adaptiveColor
case "background": case "background":
theme.BackgroundColor = adaptiveColor theme.BackgroundColor = adaptiveColor
case "backgroundsecondary": case "backgroundsubtle":
theme.BackgroundSecondaryColor = adaptiveColor theme.BackgroundSubtleColor = adaptiveColor
case "backgrounddarker": case "backgroundelement":
theme.BackgroundDarkerColor = adaptiveColor theme.BackgroundElementColor = adaptiveColor
case "bordernormal": case "border":
theme.BorderNormalColor = adaptiveColor theme.BorderColor = adaptiveColor
case "borderfocused": case "borderactive":
theme.BorderFocusedColor = adaptiveColor theme.BorderActiveColor = adaptiveColor
case "borderdim": case "bordersubtle":
theme.BorderDimColor = adaptiveColor theme.BorderSubtleColor = adaptiveColor
case "diffadded": case "diffadded":
theme.DiffAddedColor = adaptiveColor theme.DiffAddedColor = adaptiveColor
case "diffremoved": case "diffremoved":

View file

@ -85,35 +85,31 @@ func NewMonokaiProTheme() *MonokaiProTheme {
Dark: darkComment, Dark: darkComment,
Light: lightComment, Light: lightComment,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkBackground,
Light: lightBackground, Light: lightBackground,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkCurrentLine,
Light: lightCurrentLine, Light: lightCurrentLine,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#221f22", // Slightly darker than background Dark: "#221f22", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background Light: "#ffffff", // Slightly lighter than background
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkBorder,
Light: lightBorder, Light: lightBorder,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkCyan, Dark: darkCyan,
Light: lightCyan, Light: lightCyan,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkSelection,
Light: lightSelection, Light: lightSelection,
} }

View file

@ -86,35 +86,31 @@ func NewOneDarkTheme() *OneDarkTheme {
Dark: darkComment, Dark: darkComment,
Light: lightComment, Light: lightComment,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkBackground,
Light: lightBackground, Light: lightBackground,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkCurrentLine,
Light: lightCurrentLine, Light: lightCurrentLine,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#21252b", // Slightly darker than background Dark: "#21252b", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background Light: "#ffffff", // Slightly lighter than background
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkBorder,
Light: lightBorder, Light: lightBorder,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkBlue, Dark: darkBlue,
Light: lightBlue, Light: lightBlue,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkSelection,
Light: lightSelection, Light: lightSelection,
} }

View file

@ -12,14 +12,23 @@ type OpenCodeTheme struct {
// NewOpenCodeTheme creates a new instance of the OpenCode theme. // NewOpenCodeTheme creates a new instance of the OpenCode theme.
func NewOpenCodeTheme() *OpenCodeTheme { func NewOpenCodeTheme() *OpenCodeTheme {
// OpenCode color palette // OpenCode color palette with Radix-inspired scale progression
// Dark mode colors // Dark mode colors - using a neutral gray scale as base
darkBackground := "#212121" darkStep1 := "#0a0a0a" // App background
darkCurrentLine := "#252525" darkStep2 := "#141414" // Subtle background
darkSelection := "#303030" darkStep3 := "#1e1e1e" // UI element background
darkForeground := "#e0e0e0" darkStep4 := "#282828" // Hovered UI element background
darkComment := "#6a6a6a" darkStep5 := "#323232" // Active/Selected UI element background
darkPrimary := "#fab283" // Primary orange/gold darkStep6 := "#3c3c3c" // Subtle borders and separators
darkStep7 := "#484848" // UI element border and focus rings
darkStep8 := "#606060" // Hovered UI element border
darkStep9 := "#fab283" // Solid backgrounds (primary orange/gold)
darkStep10 := "#ffc09f" // Hovered solid backgrounds
darkStep11 := "#808080" // Low-contrast text (more muted)
darkStep12 := "#eeeeee" // High-contrast text
// Dark mode accent colors
darkPrimary := darkStep9 // Primary uses step 9 (solid background)
darkSecondary := "#5c9cf5" // Secondary blue darkSecondary := "#5c9cf5" // Secondary blue
darkAccent := "#9d7cd8" // Accent purple darkAccent := "#9d7cd8" // Accent purple
darkRed := "#e06c75" // Error red darkRed := "#e06c75" // Error red
@ -27,15 +36,23 @@ func NewOpenCodeTheme() *OpenCodeTheme {
darkGreen := "#7fd88f" // Success green darkGreen := "#7fd88f" // Success green
darkCyan := "#56b6c2" // Info cyan darkCyan := "#56b6c2" // Info cyan
darkYellow := "#e5c07b" // Emphasized text darkYellow := "#e5c07b" // Emphasized text
darkBorder := "#4b4c5c" // Border color
// Light mode colors // Light mode colors - using a neutral gray scale as base
lightBackground := "#f8f8f8" lightStep1 := "#ffffff" // App background
lightCurrentLine := "#f0f0f0" lightStep2 := "#fafafa" // Subtle background
lightSelection := "#e5e5e6" lightStep3 := "#f5f5f5" // UI element background
lightForeground := "#2a2a2a" lightStep4 := "#ebebeb" // Hovered UI element background
lightComment := "#8a8a8a" lightStep5 := "#e1e1e1" // Active/Selected UI element background
lightPrimary := "#3b7dd8" // Primary blue lightStep6 := "#d4d4d4" // Subtle borders and separators
lightStep7 := "#b8b8b8" // UI element border and focus rings
lightStep8 := "#a0a0a0" // Hovered UI element border
lightStep9 := "#3b7dd8" // Solid backgrounds (primary blue)
lightStep10 := "#2968c3" // Hovered solid backgrounds
lightStep11 := "#8a8a8a" // Low-contrast text (more muted)
lightStep12 := "#1a1a1a" // High-contrast text
// Light mode accent colors
lightPrimary := lightStep9 // Primary uses step 9 (solid background)
lightSecondary := "#7b5bb6" // Secondary purple lightSecondary := "#7b5bb6" // Secondary purple
lightAccent := "#d68c27" // Accent orange/gold lightAccent := "#d68c27" // Accent orange/gold
lightRed := "#d1383d" // Error red lightRed := "#d1383d" // Error red
@ -43,7 +60,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
lightGreen := "#3d9a57" // Success green lightGreen := "#3d9a57" // Success green
lightCyan := "#318795" // Info cyan lightCyan := "#318795" // Info cyan
lightYellow := "#b0851f" // Emphasized text lightYellow := "#b0851f" // Emphasized text
lightBorder := "#d3d3d3" // Border color
// Unused variables to avoid compiler errors (these could be used for hover states)
_ = darkStep4
_ = darkStep5
_ = darkStep10
_ = lightStep4
_ = lightStep5
_ = lightStep10
theme := &OpenCodeTheme{} theme := &OpenCodeTheme{}
@ -81,44 +105,40 @@ func NewOpenCodeTheme() *OpenCodeTheme {
// Text colors // Text colors
theme.TextColor = lipgloss.AdaptiveColor{ theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
theme.TextMutedColor = lipgloss.AdaptiveColor{ theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
} }
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkStep1,
Light: lightBackground, Light: lightStep1,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkStep2,
Light: lightCurrentLine, Light: lightStep2,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#121212", // Slightly darker than background Dark: darkStep3,
Light: "#ffffff", // Slightly lighter than background Light: lightStep3,
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkStep7,
Light: lightBorder, Light: lightStep7,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkPrimary, Dark: darkStep8,
Light: lightPrimary, Light: lightStep8,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkStep6,
Light: lightSelection, Light: lightStep6,
} }
// Diff view colors // Diff view colors
@ -155,12 +175,12 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: "#FFEBEE", Light: "#FFEBEE",
} }
theme.DiffContextBgColor = lipgloss.AdaptiveColor{ theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkStep2,
Light: lightBackground, Light: lightStep2,
} }
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#888888", Dark: darkStep3,
Light: "#9E9E9E", Light: lightStep3,
} }
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#293229", Dark: "#293229",
@ -173,8 +193,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
// Markdown colors // Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{ theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkSecondary, Dark: darkSecondary,
@ -205,8 +225,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightAccent, Light: lightAccent,
} }
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
} }
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkPrimary, Dark: darkPrimary,
@ -225,14 +245,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightCyan, Light: lightCyan,
} }
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
// Syntax highlighting colors // Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
} }
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkSecondary, Dark: darkSecondary,
@ -263,8 +283,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightCyan, Light: lightCyan,
} }
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
return theme return theme

View file

@ -11,32 +11,31 @@ import (
// All colors must be defined as lipgloss.AdaptiveColor to support // All colors must be defined as lipgloss.AdaptiveColor to support
// both light and dark terminal backgrounds. // both light and dark terminal backgrounds.
type Theme interface { type Theme interface {
// Base colors // Background colors
Primary() lipgloss.AdaptiveColor Background() lipgloss.AdaptiveColor // Radix 1
BackgroundSubtle() lipgloss.AdaptiveColor // Radix 2
BackgroundElement() lipgloss.AdaptiveColor // Radix 3
// Border colors
BorderSubtle() lipgloss.AdaptiveColor // Radix 6
Border() lipgloss.AdaptiveColor // Radix 7
BorderActive() lipgloss.AdaptiveColor // Radix 8
// Brand colors
Primary() lipgloss.AdaptiveColor // Radix 9
Secondary() lipgloss.AdaptiveColor Secondary() lipgloss.AdaptiveColor
Accent() lipgloss.AdaptiveColor Accent() lipgloss.AdaptiveColor
// Text colors
TextMuted() lipgloss.AdaptiveColor // Radix 11
Text() lipgloss.AdaptiveColor // Radix 12
// Status colors // Status colors
Error() lipgloss.AdaptiveColor Error() lipgloss.AdaptiveColor
Warning() lipgloss.AdaptiveColor Warning() lipgloss.AdaptiveColor
Success() lipgloss.AdaptiveColor Success() lipgloss.AdaptiveColor
Info() lipgloss.AdaptiveColor Info() lipgloss.AdaptiveColor
// Text colors
Text() lipgloss.AdaptiveColor
TextMuted() lipgloss.AdaptiveColor
TextEmphasized() lipgloss.AdaptiveColor
// Background colors
Background() lipgloss.AdaptiveColor
BackgroundSecondary() lipgloss.AdaptiveColor
BackgroundDarker() lipgloss.AdaptiveColor
// Border colors
BorderNormal() lipgloss.AdaptiveColor
BorderFocused() lipgloss.AdaptiveColor
BorderDim() lipgloss.AdaptiveColor
// Diff view colors // Diff view colors
DiffAdded() lipgloss.AdaptiveColor DiffAdded() lipgloss.AdaptiveColor
DiffRemoved() lipgloss.AdaptiveColor DiffRemoved() lipgloss.AdaptiveColor
@ -82,32 +81,31 @@ type Theme interface {
// BaseTheme provides a default implementation of the Theme interface // BaseTheme provides a default implementation of the Theme interface
// that can be embedded in concrete theme implementations. // that can be embedded in concrete theme implementations.
type BaseTheme struct { type BaseTheme struct {
// Base colors // Background colors
BackgroundColor lipgloss.AdaptiveColor
BackgroundSubtleColor lipgloss.AdaptiveColor
BackgroundElementColor lipgloss.AdaptiveColor
// Border colors
BorderSubtleColor lipgloss.AdaptiveColor
BorderColor lipgloss.AdaptiveColor
BorderActiveColor lipgloss.AdaptiveColor
// Brand colors
PrimaryColor lipgloss.AdaptiveColor PrimaryColor lipgloss.AdaptiveColor
SecondaryColor lipgloss.AdaptiveColor SecondaryColor lipgloss.AdaptiveColor
AccentColor lipgloss.AdaptiveColor AccentColor lipgloss.AdaptiveColor
// Text colors
TextMutedColor lipgloss.AdaptiveColor
TextColor lipgloss.AdaptiveColor
// Status colors // Status colors
ErrorColor lipgloss.AdaptiveColor ErrorColor lipgloss.AdaptiveColor
WarningColor lipgloss.AdaptiveColor WarningColor lipgloss.AdaptiveColor
SuccessColor lipgloss.AdaptiveColor SuccessColor lipgloss.AdaptiveColor
InfoColor lipgloss.AdaptiveColor InfoColor lipgloss.AdaptiveColor
// Text colors
TextColor lipgloss.AdaptiveColor
TextMutedColor lipgloss.AdaptiveColor
TextEmphasizedColor lipgloss.AdaptiveColor
// Background colors
BackgroundColor lipgloss.AdaptiveColor
BackgroundSecondaryColor lipgloss.AdaptiveColor
BackgroundDarkerColor lipgloss.AdaptiveColor
// Border colors
BorderNormalColor lipgloss.AdaptiveColor
BorderFocusedColor lipgloss.AdaptiveColor
BorderDimColor lipgloss.AdaptiveColor
// Diff view colors // Diff view colors
DiffAddedColor lipgloss.AdaptiveColor DiffAddedColor lipgloss.AdaptiveColor
DiffRemovedColor lipgloss.AdaptiveColor DiffRemovedColor lipgloss.AdaptiveColor
@ -160,17 +158,16 @@ func (t *BaseTheme) Warning() lipgloss.AdaptiveColor { return t.WarningColor }
func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor } func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor }
func (t *BaseTheme) Info() lipgloss.AdaptiveColor { return t.InfoColor } func (t *BaseTheme) Info() lipgloss.AdaptiveColor { return t.InfoColor }
func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor } func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor }
func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor } func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor }
func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor } func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor }
func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor } func (t *BaseTheme) BackgroundSubtle() lipgloss.AdaptiveColor { return t.BackgroundSubtleColor }
func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor { return t.BackgroundDarkerColor } func (t *BaseTheme) BackgroundElement() lipgloss.AdaptiveColor { return t.BackgroundElementColor }
func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor { return t.BorderNormalColor } func (t *BaseTheme) Border() lipgloss.AdaptiveColor { return t.BorderColor }
func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor } func (t *BaseTheme) BorderActive() lipgloss.AdaptiveColor { return t.BorderActiveColor }
func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor { return t.BorderDimColor } func (t *BaseTheme) BorderSubtle() lipgloss.AdaptiveColor { return t.BorderSubtleColor }
func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor } func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor }
func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor } func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor }

View file

@ -1,89 +0,0 @@
package theme
import (
"testing"
)
func TestThemeRegistration(t *testing.T) {
// Get list of available themes
availableThemes := AvailableThemes()
// Check if "catppuccin" theme is registered
catppuccinFound := false
for _, themeName := range availableThemes {
if themeName == "catppuccin" {
catppuccinFound = true
break
}
}
if !catppuccinFound {
t.Errorf("Catppuccin theme is not registered")
}
// Check if "gruvbox" theme is registered
gruvboxFound := false
for _, themeName := range availableThemes {
if themeName == "gruvbox" {
gruvboxFound = true
break
}
}
if !gruvboxFound {
t.Errorf("Gruvbox theme is not registered")
}
// Check if "monokai" theme is registered
monokaiFound := false
for _, themeName := range availableThemes {
if themeName == "monokai" {
monokaiFound = true
break
}
}
if !monokaiFound {
t.Errorf("Monokai theme is not registered")
}
// Try to get the themes and make sure they're not nil
catppuccin := GetTheme("catppuccin")
if catppuccin == nil {
t.Errorf("Catppuccin theme is nil")
}
gruvbox := GetTheme("gruvbox")
if gruvbox == nil {
t.Errorf("Gruvbox theme is nil")
}
monokai := GetTheme("monokai")
if monokai == nil {
t.Errorf("Monokai theme is nil")
}
// Test switching theme
originalTheme := CurrentThemeName()
err := SetTheme("gruvbox")
if err != nil {
t.Errorf("Failed to set theme to gruvbox: %v", err)
}
if CurrentThemeName() != "gruvbox" {
t.Errorf("Theme not properly switched to gruvbox")
}
err = SetTheme("monokai")
if err != nil {
t.Errorf("Failed to set theme to monokai: %v", err)
}
if CurrentThemeName() != "monokai" {
t.Errorf("Theme not properly switched to monokai")
}
// Switch back to original theme
_ = SetTheme(originalTheme)
}

View file

@ -12,36 +12,60 @@ type TokyoNightTheme struct {
// NewTokyoNightTheme creates a new instance of the Tokyo Night theme. // NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
func NewTokyoNightTheme() *TokyoNightTheme { func NewTokyoNightTheme() *TokyoNightTheme {
// Tokyo Night color palette // Tokyo Night color palette with Radix-inspired scale progression
// Dark mode colors // Dark mode colors - Tokyo Night Moon variant
darkBackground := "#222436" darkStep1 := "#1a1b26" // App background (bg)
darkCurrentLine := "#1e2030" darkStep2 := "#1e2030" // Subtle background (bg_dark)
darkSelection := "#2f334d" darkStep3 := "#222436" // UI element background (bg_highlight)
darkForeground := "#c8d3f5" darkStep4 := "#292e42" // Hovered UI element background
darkComment := "#636da6" darkStep5 := "#3b4261" // Active/Selected UI element background (bg_visual)
darkStep6 := "#545c7e" // Subtle borders and separators (dark3)
darkStep7 := "#737aa2" // UI element border and focus rings (dark5)
darkStep8 := "#9099b2" // Hovered UI element border
darkStep9 := "#82aaff" // Solid backgrounds (blue)
darkStep10 := "#89b4fa" // Hovered solid backgrounds
darkStep11 := "#828bb8" // Low-contrast text (using fg_dark for better contrast)
darkStep12 := "#c8d3f5" // High-contrast text (fg)
// Dark mode accent colors
darkRed := "#ff757f" darkRed := "#ff757f"
darkOrange := "#ff966c" darkOrange := "#ff966c"
darkYellow := "#ffc777" darkYellow := "#ffc777"
darkGreen := "#c3e88d" darkGreen := "#c3e88d"
darkCyan := "#86e1fc" darkCyan := "#86e1fc"
darkBlue := "#82aaff" darkBlue := darkStep9 // Using step 9 for primary
darkPurple := "#c099ff" darkPurple := "#c099ff"
darkBorder := "#3b4261"
// Light mode colors (Tokyo Night Day) // Light mode colors - Tokyo Night Day variant
lightBackground := "#e1e2e7" lightStep1 := "#e1e2e7" // App background
lightCurrentLine := "#d5d6db" lightStep2 := "#d5d6db" // Subtle background
lightSelection := "#c8c9ce" lightStep3 := "#c8c9ce" // UI element background
lightForeground := "#3760bf" lightStep4 := "#b9bac1" // Hovered UI element background
lightComment := "#848cb5" lightStep5 := "#a8aecb" // Active/Selected UI element background
lightStep6 := "#9699a8" // Subtle borders and separators
lightStep7 := "#737a8c" // UI element border and focus rings
lightStep8 := "#5a607d" // Hovered UI element border
lightStep9 := "#2e7de9" // Solid backgrounds (blue)
lightStep10 := "#1a6ce7" // Hovered solid backgrounds
lightStep11 := "#8990a3" // Low-contrast text (more muted)
lightStep12 := "#3760bf" // High-contrast text
// Light mode accent colors
lightRed := "#f52a65" lightRed := "#f52a65"
lightOrange := "#b15c00" lightOrange := "#b15c00"
lightYellow := "#8c6c3e" lightYellow := "#8c6c3e"
lightGreen := "#587539" lightGreen := "#587539"
lightCyan := "#007197" lightCyan := "#007197"
lightBlue := "#2e7de9" lightBlue := lightStep9 // Using step 9 for primary
lightPurple := "#9854f1" lightPurple := "#9854f1"
lightBorder := "#a8aecb"
// Unused variables to avoid compiler errors (these could be used for hover states)
_ = darkStep4
_ = darkStep5
_ = darkStep10
_ = lightStep4
_ = lightStep5
_ = lightStep10
theme := &TokyoNightTheme{} theme := &TokyoNightTheme{}
@ -79,44 +103,40 @@ func NewTokyoNightTheme() *TokyoNightTheme {
// Text colors // Text colors
theme.TextColor = lipgloss.AdaptiveColor{ theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
theme.TextMutedColor = lipgloss.AdaptiveColor{ theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
} }
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkStep1,
Light: lightBackground, Light: lightStep1,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkStep2,
Light: lightCurrentLine, Light: lightStep2,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#191B29", // Darker background from palette Dark: darkStep3,
Light: "#f0f0f5", // Slightly lighter than background Light: lightStep3,
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkStep7,
Light: lightBorder, Light: lightStep7,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkBlue, Dark: darkStep8,
Light: lightBlue, Light: lightStep8,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkStep6,
Light: lightSelection, Light: lightStep6,
} }
// Diff view colors // Diff view colors
@ -153,12 +173,12 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: "#f7d8db", Light: "#f7d8db",
} }
theme.DiffContextBgColor = lipgloss.AdaptiveColor{ theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkStep2,
Light: lightBackground, Light: lightStep2,
} }
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#545c7e", // dark3 from palette Dark: darkStep3, // dark3 from palette
Light: "#848cb5", Light: lightStep3,
} }
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#1b2b34", Dark: "#1b2b34",
@ -171,8 +191,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
// Markdown colors // Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{ theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPurple, Dark: darkPurple,
@ -203,8 +223,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightOrange, Light: lightOrange,
} }
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
} }
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkBlue, Dark: darkBlue,
@ -223,14 +243,14 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightCyan, Light: lightCyan,
} }
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
// Syntax highlighting colors // Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment, Dark: darkStep11,
Light: lightComment, Light: lightStep11,
} }
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkPurple, Dark: darkPurple,
@ -261,8 +281,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightCyan, Light: lightCyan,
} }
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground, Dark: darkStep12,
Light: lightForeground, Light: lightStep12,
} }
return theme return theme

View file

@ -88,35 +88,31 @@ func NewTronTheme() *TronTheme {
Dark: darkComment, Dark: darkComment,
Light: lightComment, Light: lightComment,
} }
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors // Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground, Dark: darkBackground,
Light: lightBackground, Light: lightBackground,
} }
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine, Dark: darkCurrentLine,
Light: lightCurrentLine, Light: lightCurrentLine,
} }
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#070d14", // Slightly darker than background Dark: "#070d14", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background Light: "#ffffff", // Slightly lighter than background
} }
// Border colors // Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder, Dark: darkBorder,
Light: lightBorder, Light: lightBorder,
} }
theme.BorderFocusedColor = lipgloss.AdaptiveColor{ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkCyan, Dark: darkCyan,
Light: lightCyan, Light: lightCyan,
} }
theme.BorderDimColor = lipgloss.AdaptiveColor{ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection, Dark: darkSelection,
Light: lightSelection, Light: lightSelection,
} }

View file

@ -158,7 +158,7 @@ func (a appModel) Init() tea.Cmd {
// Check if we should show the init dialog // Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil shouldShow := app.Info.Git && app.Info.Time.Initialized == nil
return dialog.ShowInitDialogMsg{Show: shouldShow} return dialog.ShowInitDialogMsg{Show: shouldShow}
}) })
@ -212,6 +212,27 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.Height -= 2 // Make space for the status bar msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height a.width, a.height = msg.Width, msg.Height
size := layout.LayoutSizeNormal
if a.width < 40 {
size = layout.LayoutSizeSmall
} else if a.width < 80 {
size = layout.LayoutSizeNormal
} else {
size = layout.LayoutSizeLarge
}
// TODO: move away from global state
layout.Current = &layout.LayoutInfo{
Size: size,
Viewport: layout.Dimensions{
Width: a.width,
Height: a.height,
},
Container: layout.Dimensions{
Width: min(a.width, 80),
},
}
s, _ := a.status.Update(msg) s, _ := a.status.Update(msg)
a.status = s.(core.StatusCmp) a.status = s.(core.StatusCmp)
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@ -711,7 +732,6 @@ func (a appModel) View() string {
components := []string{ components := []string{
a.pages[a.currentPage].View(), a.pages[a.currentPage].View(),
} }
components = append(components, a.status.View()) components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...) appView := lipgloss.JoinVertical(lipgloss.Top, components...)
@ -943,7 +963,7 @@ func NewModel(app *app.App) tea.Model {
}) })
// Load custom commands // Load custom commands
customCommands, err := dialog.LoadCustomCommands(app) customCommands, err := dialog.LoadCustomCommands()
if err != nil { if err != nil {
slog.Warn("Failed to load custom commands", "error", err) slog.Warn("Failed to load custom commands", "error", err)
} else { } else {

View file

@ -11,6 +11,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
} }
func Clamp(v, low, high int) int { func Clamp(v, low, high int) int {
// Swap if needed to ensure low <= high
if high < low { if high < low {
low, high = high, low low, high = high, low
} }

View file

@ -59,9 +59,9 @@ You can define any of the following color keys in your `customTheme`.
| ----------------- | ------------------------------------------------------- | | ----------------- | ------------------------------------------------------- |
| Base colors | `primary`, `secondary`, `accent` | | Base colors | `primary`, `secondary`, `accent` |
| Status colors | `error`, `warning`, `success`, `info` | | Status colors | `error`, `warning`, `success`, `info` |
| Text colors | `text`, `textMuted`, `textEmphasized` | | Text colors | `text`, `textMuted` |
| Background colors | `background`, `backgroundSecondary`, `backgroundDarker` | | Background colors | `background`, `backgroundSubtle`, `backgroundElement` |
| Border colors | `borderNormal`, `borderFocused`, `borderDim` | | Border colors | `border`, `borderActive`, `borderSubtle` |
| Diff view colors | `diffAdded`, `diffRemoved`, `diffContext`, etc. | | Diff view colors | `diffAdded`, `diffRemoved`, `diffContext`, etc. |
You don't need to define all the color keys. Any undefined colors will fall back to the default `opencode` theme colors. You don't need to define all the color keys. Any undefined colors will fall back to the default `opencode` theme colors.