summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--README.md70
-rw-r--r--packages/tui/cmd/opencode/main.go7
-rw-r--r--packages/tui/internal/app/app.go19
-rw-r--r--packages/tui/internal/components/chat/cache.go36
-rw-r--r--packages/tui/internal/components/chat/chat.go109
-rw-r--r--packages/tui/internal/components/chat/editor.go254
-rw-r--r--packages/tui/internal/components/chat/message.go490
-rw-r--r--packages/tui/internal/components/chat/messages.go393
-rw-r--r--packages/tui/internal/components/chat/sidebar.go212
-rw-r--r--packages/tui/internal/components/core/status.go172
-rw-r--r--packages/tui/internal/components/dialog/custom_commands.go2
-rw-r--r--packages/tui/internal/components/diff/diff.go35
-rw-r--r--packages/tui/internal/components/spinner/spinner.go127
-rw-r--r--packages/tui/internal/components/spinner/spinner_test.go24
-rw-r--r--packages/tui/internal/layout/container.go52
-rw-r--r--packages/tui/internal/layout/flex.go248
-rw-r--r--packages/tui/internal/layout/layout.go29
-rw-r--r--packages/tui/internal/layout/overlay.go7
-rw-r--r--packages/tui/internal/layout/split.go283
-rw-r--r--packages/tui/internal/page/chat.go46
-rw-r--r--packages/tui/internal/styles/styles.go61
-rw-r--r--packages/tui/internal/theme/ayu.go14
-rw-r--r--packages/tui/internal/theme/catppuccin.go14
-rw-r--r--packages/tui/internal/theme/dracula.go20
-rw-r--r--packages/tui/internal/theme/flexoki.go14
-rw-r--r--packages/tui/internal/theme/gruvbox.go14
-rw-r--r--packages/tui/internal/theme/manager.go22
-rw-r--r--packages/tui/internal/theme/monokai.go14
-rw-r--r--packages/tui/internal/theme/onedark.go14
-rw-r--r--packages/tui/internal/theme/opencode.go132
-rw-r--r--packages/tui/internal/theme/theme.go81
-rw-r--r--packages/tui/internal/theme/theme_test.go89
-rw-r--r--packages/tui/internal/theme/tokyonight.go132
-rw-r--r--packages/tui/internal/theme/tron.go14
-rw-r--r--packages/tui/internal/tui/tui.go26
-rw-r--r--packages/tui/internal/util/util.go1
-rw-r--r--packages/web/src/content/docs/docs/themes.mdx6
37 files changed, 1489 insertions, 1794 deletions
diff --git a/README.md b/README.md
index 25fad7418..ae847d766 100644
--- a/README.md
+++ b/README.md
@@ -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) |
| `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
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go
index d6c4d3b0e..27e797e8b 100644
--- a/packages/tui/cmd/opencode/main.go
+++ b/packages/tui/cmd/opencode/main.go
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
@@ -51,7 +52,11 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
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 {
panic(err)
}
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 9ce5c2a66..cffc4b1cf 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -21,7 +21,6 @@ import (
type App struct {
ConfigPath string
Config *config.Config
- Info *client.AppInfo
Client *client.ClientWithResponses
Provider *client.ProviderInfo
Model *client.ProviderModel
@@ -34,7 +33,14 @@ type App struct {
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()
if err != nil {
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)
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)
if err != nil {
return nil, err
@@ -70,7 +82,7 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
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)
if err != nil {
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{
ConfigPath: appConfigPath,
Config: appConfig,
- Info: appInfo,
Client: httpClient,
Provider: currentProvider,
Model: currentModel,
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go
index 5219e7092..1586c2cc3 100644
--- a/packages/tui/internal/components/chat/cache.go
+++ b/packages/tui/internal/components/chat/cache.go
@@ -5,8 +5,6 @@ import (
"encoding/hex"
"fmt"
"sync"
-
- "github.com/sst/opencode/pkg/client"
)
// 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
-func (c *MessageCache) generateKey(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) string {
- // Create a hash of the message content and rendering parameters
+func (c *MessageCache) GenerateKey(params ...any) string {
h := sha256.New()
-
- // Include message ID and role
- 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))
+ for _, param := range params {
+ h.Write(fmt.Appendf(nil, ":%v", param))
}
-
return hex.EncodeToString(h.Sum(nil))
}
// 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()
defer c.mu.RUnlock()
- key := c.generateKey(msg, width, showToolMessages, appInfo)
content, exists := c.cache[key]
return content, exists
}
// 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()
defer c.mu.Unlock()
-
- key := c.generateKey(msg, width, showToolMessages, appInfo)
c.cache[key] = content
}
diff --git a/packages/tui/internal/components/chat/chat.go b/packages/tui/internal/components/chat/chat.go
index ad06728d3..29487efb7 100644
--- a/packages/tui/internal/components/chat/chat.go
+++ b/packages/tui/internal/components/chat/chat.go
@@ -1,11 +1,6 @@
package chat
import (
- "fmt"
- "sort"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -16,100 +11,6 @@ type SendMsg struct {
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 {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
@@ -119,13 +20,3 @@ func repo(width int) string {
Width(width).
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)
-}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 305365a25..52f198492 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -10,6 +10,7 @@ import (
"unicode"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -23,7 +24,7 @@ import (
"github.com/sst/opencode/internal/util"
)
-type editorCmp struct {
+type editorComponent struct {
width int
height int
app *app.App
@@ -33,6 +34,7 @@ type editorCmp struct {
history []string
historyIndex int
currentMessage string
+ spinner spinner.Model
}
type EditorKeyMaps struct {
@@ -96,86 +98,19 @@ const (
maxAttachments = 5
)
-func (m *editorCmp) 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 *editorCmp) Init() tea.Cmd {
- return textarea.Blink
-}
-
-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 *editorComponent) Init() tea.Cmd {
+ return tea.Batch(textarea.Blink, m.spinner.Tick)
}
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
- m.textarea = CreateTextArea(&m.textarea)
+ m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
@@ -296,47 +231,160 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.send()
}
}
-
}
+
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+
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()
-
- // Style the prompt with theme colors
- style := lipgloss.NewStyle().
+ base := styles.BaseStyle().Render
+ muted := styles.Muted().Render
+ promptStyle := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
+ prompt := promptStyle.Render(">")
- if len(m.attachments) == 0 {
- return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+ textarea := lipgloss.JoinHorizontal(
+ 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")
+ }
+
+ model := ""
+ if m.app.Model != nil {
+ model = base(*m.app.Model.Name) + muted(" • /model")
}
- m.textarea.SetHeight(m.height - 1)
- return lipgloss.JoinVertical(lipgloss.Top,
- m.attachmentsContent(),
- lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
- 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.height = height
- m.textarea.SetWidth(width - 3) // account for the prompt and padding right
- m.textarea.SetHeight(height)
+ m.textarea.SetWidth(width - 5) // account for the prompt and padding right
+ m.textarea.SetHeight(height - 3) // account for info underneath
return nil
}
-func (m *editorCmp) GetSize() (int, int) {
- return m.textarea.Width(), m.textarea.Height()
+func (m *editorComponent) GetSize() (int, int) {
+ return m.width, m.height
}
-func (m *editorCmp) attachmentsContent() string {
- var styledAttachments []string
+func (m *editorComponent) BindingKeys() []key.Binding {
+ 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()
+ var styledAttachments []string
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
@@ -357,20 +405,15 @@ func (m *editorCmp) attachmentsContent() string {
return content
}
-func (m *editorCmp) BindingKeys() []key.Binding {
- bindings := []key.Binding{}
- bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
- bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
- return bindings
-}
-
-func CreateTextArea(existing *textarea.Model) textarea.Model {
+func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
+ ta.Placeholder = "It's prompting time..."
+
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
@@ -394,13 +437,16 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta
}
-func NewEditorCmp(app *app.App) tea.Model {
- ta := CreateTextArea(nil)
- return &editorCmp{
+func NewEditorComponent(app *app.App) tea.Model {
+ s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
+ ta := createTextArea(nil)
+
+ return &editorComponent{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
+ spinner: s,
}
}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index c78dd8e2f..5b91427d7 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -2,13 +2,18 @@ package chat
import (
"fmt"
+ "log/slog"
"path/filepath"
+ "slices"
"strings"
"time"
+ "unicode"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
+ "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff"
+ "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
@@ -16,14 +21,12 @@ import (
"golang.org/x/text/language"
)
-const (
- maxResultHeight = 10
-)
-
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
+ content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
+
if len(lines) > 0 {
firstLine := lines[0]
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()
+ renderer := &markdownRenderer{
+ fullWidth: false,
+ }
+ for _, option := range options {
+ option(renderer)
+ }
+
style := styles.BaseStyle().
- PaddingLeft(1).
- BorderLeft(true).
+ PaddingTop(1).
+ PaddingBottom(1).
+ PaddingLeft(2).
+ PaddingRight(2).
+ Background(t.BackgroundSubtle()).
Foreground(t.TextMuted()).
- BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
- // var styledAttachments []string
- // attachmentStyles := baseStyle.
- // MarginLeft(1).
- // 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))
- // }
+ align := lipgloss.Left
+ if renderer.align != nil {
+ align = *renderer.align
+ }
- 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:]
+ borderColor := t.BackgroundSubtle()
+ if renderer.borderColor != nil {
+ borderColor = *renderer.borderColor
}
- info := styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf("%s (%s)", user, timestamp))
-
- content := ""
- // if len(styledAttachments) > 0 {
- // attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
- // content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
- // } else {
- for _, p := range msg.Parts {
- 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))
- }
+ 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())
}
- return styles.ForceReplaceBackgroundWithLipgloss(content, 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 renderAssistantMessage(
- msg client.MessageInfo,
- width int,
- showToolMessages bool,
- appInfo client.AppInfo,
-) string {
+func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
- style := styles.BaseStyle().
- PaddingLeft(1).
- BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.Primary()).
- BorderStyle(lipgloss.ThickBorder())
- messages := []string{}
+ 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(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
+ 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] {
+ // don't show the date if it's today
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?
- }
+ Render(fmt.Sprintf("%s (%s)", author, timestamp))
- switch part.(type) {
- // case client.MessagePartReasoning:
- // reasoningPart := part.(client.MessagePartReasoning)
+ align := lipgloss.Left
+ switch message.Role {
+ case client.User:
+ align = lipgloss.Right
+ case client.Assistant:
+ align = lipgloss.Left
+ }
- 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)
+ 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)
- case client.MessagePartToolInvocation:
- if !showToolMessages {
- continue
- }
+ 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 ""
+}
- 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)
- }
+func renderToolInvocation(
+ toolCall client.MessageToolInvocationToolCall,
+ result *string,
+ metadata map[string]any,
+ showResult bool,
+) string {
+ ignoredTools := []string{"opencode_todoread"}
+ if slices.Contains(ignoredTools, toolCall.ToolName) {
+ return ""
}
- 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()
- style := styles.BaseStyle().
+ style := styles.Muted().
+ Width(outerWidth).
+ PaddingLeft(padding).
BorderLeft(true).
- PaddingLeft(1).
- Foreground(t.TextMuted()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.BorderSubtle()).
BorderStyle(lipgloss.ThickBorder())
- toolName := renderToolName(toolCall.ToolName)
+ if toolCall.State == "partial-call" {
+ style = style.Foreground(t.TextMuted())
+ return style.Render(renderToolAction(toolCall.ToolName))
+ }
+
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
@@ -185,17 +253,20 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
firstKey = key
break
}
- toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey)
+ toolArgs = renderArgs(&toolArgsMap, firstKey)
}
}
- title := fmt.Sprintf("%s: %s", toolName, toolArgs)
- finished := result != nil
- body := styles.BaseStyle().Render("In progress...")
+ if len(toolArgsMap) == 0 {
+ slog.Debug("no args")
+ }
+
+ body := ""
+ finished := result != nil && *result != ""
if finished {
body = *result
}
- footer := ""
+ elapsed := ""
if metadata["time"] != nil {
timeMap := metadata["time"].(map[string]any)
start := timeMap["start"].(float64)
@@ -206,84 +277,54 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
if durationMs > 1000 {
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 {
+ 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":
filename := toolArgsMap["filePath"].(string)
- filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
- title = fmt.Sprintf("%s: %s", toolName, filename)
- if finished && metadata["diff"] != nil {
+ title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
+ if metadata["diff"] != nil {
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)
- return style.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
+ body = lipgloss.Place(
+ layout.Current.Viewport.Width,
+ lipgloss.Height(body)+2,
+ lipgloss.Center,
+ lipgloss.Center,
body,
- styles.ForceReplaceBackgroundWithLipgloss(footer, 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)
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ )
}
case "opencode_write":
filename := toolArgsMap["filePath"].(string)
- filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
- title = fmt.Sprintf("%s: %s", toolName, filename)
- ext := filepath.Ext(filename)
- if ext == "" {
- ext = ""
- } else {
- ext = strings.ToLower(ext[1:])
- }
+ title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
content := toolArgsMap["content"].(string)
- body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10))
- body = toMarkdown(body, width)
+ body = renderFile(filename, content)
case "opencode_bash":
- if finished && metadata["stdout"] != nil {
- description := toolArgsMap["description"].(string)
- title = fmt.Sprintf("%s: %s", toolName, description)
+ description := toolArgsMap["description"].(string)
+ title = fmt.Sprintf("Shell: %s %s", description, elapsed)
+ if metadata["stdout"] != nil {
command := toolArgsMap["command"].(string)
stdout := metadata["stdout"].(string)
- body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout)
- body = toMarkdown(body, width)
- }
- 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)
+ body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
+ body = toMarkdown(body, innerWidth)
+ body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
case "opencode_todowrite":
- title = fmt.Sprintf("%s", toolName)
+ title = fmt.Sprintf("Planning... %s", elapsed)
if finished && metadata["todos"] != nil {
body = ""
todos := metadata["todos"].([]any)
@@ -299,23 +340,35 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
- body = toMarkdown(body, width)
+ body = toMarkdown(body, innerWidth)
+ body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
default:
- body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
- body = toMarkdown(body, width)
+ toolName := renderToolName(toolCall.ToolName)
+ 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 {
- 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,
- title,
- body,
- footer,
- ))
- return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ content := style.Render(title)
+ content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
+ content = styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ if showResult && body != "" {
+ content += "\n" + body
+ }
+ return content
+ // return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderToolName(name string) string {
@@ -327,9 +380,9 @@ func renderToolName(name string) string {
case "opencode_webfetch":
return "Fetch"
case "opencode_todoread":
- return "Read TODOs"
+ return "Planning"
case "opencode_todowrite":
- return "Update TODOs"
+ return "Planning"
default:
normalizedName := name
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 {
switch name {
// case agent.AgentToolName:
@@ -367,7 +473,7 @@ func renderToolAction(name string) string {
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 {
return ""
}
@@ -375,7 +481,7 @@ func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) s
parts := []string{}
for key, value := range *args {
if key == "filePath" || key == "path" {
- value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/")
+ value = relative(value.(string))
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
@@ -396,3 +502,17 @@ func truncateHeight(content string, height int) string {
}
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
+}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index f39884e7a..093c8cf94 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -1,7 +1,7 @@
package chat
import (
- "fmt"
+ "strings"
"time"
"github.com/charmbracelet/bubbles/key"
@@ -11,21 +11,23 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
+ "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
-type messagesCmp struct {
- app *app.App
- width, height int
- viewport viewport.Model
- spinner spinner.Model
- rendering bool
- attachments viewport.Model
- showToolMessages bool
- cache *MessageCache
+type messagesComponent struct {
+ app *app.App
+ width, height int
+ viewport viewport.Model
+ spinner spinner.Model
+ rendering bool
+ attachments viewport.Model
+ showToolResults bool
+ cache *MessageCache
+ tail bool
}
type renderFinishedMsg 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)
}
-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
switch msg := msg.(type) {
+ case SendMsg:
+ m.viewport.GotoBottom()
+ m.tail = true
+ return m, nil
case dialog.ThemeChangedMsg:
m.cache.Clear()
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
- m.showToolMessages = !m.showToolMessages
+ m.showToolResults = !m.showToolResults
m.renderView()
return m, nil
case state.SessionSelectedMsg:
- // Clear cache when switching sessions
m.cache.Clear()
cmd := m.Reload()
+ m.viewport.GotoBottom()
return m, cmd
case state.SessionClearedMsg:
- // Clear cache when session is cleared
m.cache.Clear()
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
- if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
- key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
+ if key.Matches(msg, messageKeys.PageUp) ||
+ key.Matches(msg, messageKeys.PageDown) ||
+ key.Matches(msg, messageKeys.HalfPageUp) ||
+ key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
+ m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
- m.viewport.GotoBottom()
+ if m.tail {
+ m.viewport.GotoBottom()
+ }
case state.StateUpdatedMsg:
m.renderView()
- m.viewport.GotoBottom()
+ if m.tail {
+ m.viewport.GotoBottom()
+ }
}
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...)
}
-func (m *messagesCmp) renderView() {
+type blockType int
+
+const (
+ none blockType = iota
+ systemTextBlock
+ userTextBlock
+ assistantTextBlock
+ toolInvocationBlock
+)
+
+func (m *messagesComponent) renderView() {
if m.width == 0 {
return
}
- messages := make([]string, 0)
- for _, msg := range m.app.Messages {
+ blocks := make([]string, 0)
+ previousBlockType := none
+ for _, message := range m.app.Messages {
+ if message.Role == client.System {
+ continue // ignoring system messages for now
+ }
+
var content string
var cached bool
- switch msg.Role {
+ author := ""
+ switch message.Role {
case client.User:
- content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
- 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")
+ author = app.Info.User
case client.Assistant:
- content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
- if !cached {
- content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
- m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
+ author = message.Metadata.Assistant.ModelID
+ }
+
+ 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(
- styles.BaseStyle().
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- messages...,
- ),
- ),
- )
-}
+ t := theme.CurrentTheme()
+ centered := []string{}
+ for _, block := range blocks {
+ centered = append(centered, lipgloss.PlaceHorizontal(
+ m.width,
+ lipgloss.Center,
+ block,
+ lipgloss.WithWhitespaceBackground(t.Background()),
+ ))
+ }
-func (m *messagesCmp) View() string {
- baseStyle := styles.BaseStyle()
+ m.viewport.Height = m.height - lipgloss.Height(m.header())
+ m.viewport.SetContent(strings.Join(centered, "\n"))
+}
- if m.rendering {
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- "Loading...",
- m.working(),
- m.help(),
- ),
- )
+func (m *messagesComponent) header() string {
+ if m.app.Session.Id == "" {
+ return ""
}
- if len(m.app.Messages) == 0 {
- content := baseStyle.
- Width(m.width).
- Height(m.height - 1).
- Render(
- m.initialScreen(),
- )
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- content,
- "",
- m.help(),
- ),
- )
+ t := theme.CurrentTheme()
+ width := layout.Current.Container.Width
+ base := styles.BaseStyle().Render
+ muted := styles.Muted().Render
+ headerLines := []string{}
+ 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))
+ } else {
+ headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}
+ header := strings.Join(headerLines, "\n")
+
+ header = styles.BaseStyle().
+ Width(width).
+ PaddingTop(1).
+ BorderBottom(true).
+ BorderForeground(t.BorderSubtle()).
+ BorderStyle(lipgloss.NormalBorder()).
+ Background(t.Background()).
+ Render(header)
+
+ return styles.ForceReplaceBackgroundWithLipgloss(header, t.Background())
+}
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- m.viewport.View(),
- m.working(),
- m.help(),
- ),
- )
+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 {
@@ -225,36 +305,7 @@ func (m *messagesCmp) View() string {
// return false
// }
-func (m *messagesCmp) working() 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 {
+func (m *messagesComponent) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -275,11 +326,7 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
- 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"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline"),
)
}
return baseStyle.
@@ -287,20 +334,83 @@ func (m *messagesCmp) help() string {
Render(text)
}
-func (m *messagesCmp) initialScreen() string {
+func (m *messagesComponent) home() string {
+ t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
+ base := baseStyle.Render
+ muted := styles.Muted().Render
+
+ // mark := `
+ // ███▀▀█
+ // ███ █
+ // ▀▀▀▀▀▀ `
+ open := `
+█▀▀█ █▀▀█ █▀▀ █▀▀▄
+█░░█ █░░█ █▀▀ █░░█
+▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
+ 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),
+ )
- return baseStyle.Width(m.width).Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.app, m.width),
- "",
- lspsConfigured(m.width),
- ),
+ 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 {
return nil
}
@@ -311,18 +421,18 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.viewport.Width = width
- m.viewport.Height = height - 2
+ m.viewport.Height = height - lipgloss.Height(m.header())
m.attachments.Width = width + 40
m.attachments.Height = 3
m.renderView()
return nil
}
-func (m *messagesCmp) GetSize() (int, int) {
+func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height
}
-func (m *messagesCmp) Reload() tea.Cmd {
+func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
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{
m.viewport.KeyMap.PageDown,
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{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
@@ -353,12 +463,13 @@ func NewMessagesCmp(app *app.App) tea.Model {
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
- return &messagesCmp{
- app: app,
- viewport: vp,
- spinner: s,
- attachments: attachments,
- showToolMessages: true,
- cache: NewMessageCache(),
+ return &messagesComponent{
+ app: app,
+ viewport: vp,
+ spinner: s,
+ attachments: attachments,
+ showToolResults: true,
+ cache: NewMessageCache(),
+ tail: true,
}
}
diff --git a/packages/tui/internal/components/chat/sidebar.go b/packages/tui/internal/components/chat/sidebar.go
deleted file mode 100644
index b2fe872ea..000000000
--- a/packages/tui/internal/components/chat/sidebar.go
+++ /dev/null
@@ -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,
- }
-}
diff --git a/packages/tui/internal/components/core/status.go b/packages/tui/internal/components/core/status.go
index 5c3e5eb3c..10a123f02 100644
--- a/packages/tui/internal/components/core/status.go
+++ b/packages/tui/internal/components/core/status.go
@@ -98,16 +98,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget() string {
+func logo() string {
t := theme.CurrentTheme()
- helpText := "ctrl+? help"
-
- return styles.Padded().
- Background(t.TextMuted()).
- Foreground(t.BackgroundDarker()).
- Bold(true).
- Render(helpText)
+ mark := styles.Bold().Foreground(t.Primary()).Render("◧ ")
+ open := styles.Muted().Render("open")
+ code := styles.BaseStyle().Bold(true).Render("code")
+ version := styles.Muted().Render(app.Info.Version)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ styles.Padded().Render(mark+open+code+" "+version),
+ t.BackgroundElement(),
+ )
}
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
formattedCost := fmt.Sprintf("$%.2f", cost)
-
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusCmp) View() string {
+ if m.app.Session.Id == "" {
+ return styles.BaseStyle().
+ Width(m.width).
+ Height(2).
+ Render("")
+ }
+
t := theme.CurrentTheme()
- status := getHelpWidget()
+ logo := logo()
+ cwd := styles.Padded().
+ Foreground(t.TextMuted()).
+ Background(t.BackgroundSubtle()).
+ Render(app.Info.Path.Cwd)
+
+ sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
@@ -157,87 +169,85 @@ func (m statusCmp) View() string {
}
}
- tokensInfo := styles.Padded().
- Background(t.Text()).
- Foreground(t.BackgroundSecondary()).
+ sessionInfo = styles.Padded().
+ Background(t.BackgroundElement()).
+ Foreground(t.TextMuted()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
- status += tokensInfo
}
- diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
-
- modelName := m.model()
+ // diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
- statusWidth := max(
+ space := max(
0,
- m.width-
- lipgloss.Width(status)-
- lipgloss.Width(modelName)-
- lipgloss.Width(diagnostics),
+ m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
)
+ spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
- const minInlineWidth = 30
+ status := logo + cwd + spacer + sessionInfo
- // Display the first status message if available
- var statusMessage string
- if len(m.queue) > 0 {
- sm := m.queue[0]
- infoStyle := styles.Padded().
- 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())
- }
+ blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+ return blank + "\n" + status
- // 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("")
- }
+ // Display the first status message if available
+ // var statusMessage string
+ // if len(m.queue) > 0 {
+ // sm := m.queue[0]
+ // infoStyle := styles.Padded().
+ // 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("")
+ // }
- status += diagnostics
- status += modelName
+ // status += diagnostics
+ // status += modelName
// If we have a separate status message, prepend it
- if statusMessage != "" {
- return statusMessage + "\n" + status
- } else {
- blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
- return blank + "\n" + status
- }
+ // if statusMessage != "" {
+ // return statusMessage + "\n" + status
+ // } else {
+ // blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+ // return blank + "\n" + status
+ // }
}
func (m *statusCmp) projectDiagnostics() string {
@@ -281,7 +291,7 @@ func (m *statusCmp) projectDiagnostics() string {
// }
return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render("No diagnostics"),
- t.BackgroundDarker(),
+ t.BackgroundElement(),
)
// if len(errorDiagnostics) == 0 &&
diff --git a/packages/tui/internal/components/dialog/custom_commands.go b/packages/tui/internal/components/dialog/custom_commands.go
index e23a90f66..c0115ad7a 100644
--- a/packages/tui/internal/components/dialog/custom_commands.go
+++ b/packages/tui/internal/components/dialog/custom_commands.go
@@ -22,7 +22,7 @@ const (
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
// 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
homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go
index e3ee61c79..65874ebb4 100644
--- a/packages/tui/internal/components/diff/diff.go
+++ b/packages/tui/internal/components/diff/diff.go
@@ -404,10 +404,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
<entry type="TextWhitespace" style="%s"/>
</style>
`,
- getColor(t.Background()), // Background
- getColor(t.Text()), // Text
- getColor(t.Text()), // Other
- getColor(t.Error()), // Error
+ getColor(t.BackgroundSubtle()), // Background
+ getColor(t.Text()), // Text
+ getColor(t.Text()), // Other
+ getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
@@ -531,8 +531,7 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
- lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
-
+ lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
return
}
@@ -581,7 +580,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the appropriate color based on terminal background
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); {
// 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
-func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
- t := theme.CurrentTheme()
+func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
+ // t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
- config := NewSideBySideConfig(opts...)
+ // config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
- sb.WriteString(
- lipgloss.NewStyle().
- Background(t.DiffHunkHeader()).
- Foreground(t.Background()).
- Width(config.TotalWidth).
- Render(h.Header) + "\n",
- )
- sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
+ // sb.WriteString(
+ // lipgloss.NewStyle().
+ // Background(t.DiffHunkHeader()).
+ // Foreground(t.Background()).
+ // Width(config.TotalWidth).
+ // Render(h.Header) + "\n",
+ // )
+ sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
return sb.String(), nil
diff --git a/packages/tui/internal/components/spinner/spinner.go b/packages/tui/internal/components/spinner/spinner.go
deleted file mode 100644
index 5e1af8771..000000000
--- a/packages/tui/internal/components/spinner/spinner.go
+++ /dev/null
@@ -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
-} \ No newline at end of file
diff --git a/packages/tui/internal/components/spinner/spinner_test.go b/packages/tui/internal/components/spinner/spinner_test.go
deleted file mode 100644
index 065726e91..000000000
--- a/packages/tui/internal/components/spinner/spinner_test.go
+++ /dev/null
@@ -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
-} \ No newline at end of file
diff --git a/packages/tui/internal/layout/container.go b/packages/tui/internal/layout/container.go
index 00166b1d8..1c12ef6a1 100644
--- a/packages/tui/internal/layout/container.go
+++ b/packages/tui/internal/layout/container.go
@@ -13,6 +13,8 @@ type Container interface {
Bindings
Focus()
Blur()
+ MaxWidth() int
+ Alignment() lipgloss.Position
}
type container struct {
@@ -32,6 +34,9 @@ type container struct {
borderLeft bool
borderStyle lipgloss.Border
+ maxWidth int
+ align lipgloss.Position
+
focused bool
}
@@ -51,6 +56,11 @@ func (c *container) View() string {
width := c.width
height := c.height
+ // Apply max width constraint if set
+ if c.maxWidth > 0 && width > c.maxWidth {
+ width = c.maxWidth
+ }
+
style = style.Background(t.Background())
// Apply border if any side is enabled
@@ -74,7 +84,7 @@ func (c *container) View() string {
if c.focused {
style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
} else {
- style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
+ style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
}
}
style = style.
@@ -92,6 +102,12 @@ func (c *container) SetSize(width, height int) tea.Cmd {
c.width = width
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 sizeable, ok := c.content.(Sizeable); ok {
// 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
- contentWidth := max(0, width-horizontalSpace)
+ contentWidth := max(0, effectiveWidth-horizontalSpace)
contentHeight := max(0, height-verticalSpace)
return sizeable.SetSize(contentWidth, contentHeight)
}
@@ -124,6 +140,14 @@ func (c *container) GetSize() (int, int) {
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 {
if b, ok := c.content.(Bindings); ok {
return b.BindingKeys()
@@ -228,3 +252,27 @@ func WithThickBorder() ContainerOption {
func WithDoubleBorder() ContainerOption {
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)
+}
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
new file mode 100644
index 000000000..a0882196e
--- /dev/null
+++ b/packages/tui/internal/layout/flex.go
@@ -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
+ }
+}
+
diff --git a/packages/tui/internal/layout/layout.go b/packages/tui/internal/layout/layout.go
index 495a3fbc5..05f07f1a2 100644
--- a/packages/tui/internal/layout/layout.go
+++ b/packages/tui/internal/layout/layout.go
@@ -7,6 +7,35 @@ import (
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 {
Focus() tea.Cmd
Blur() tea.Cmd
diff --git a/packages/tui/internal/layout/overlay.go b/packages/tui/internal/layout/overlay.go
index 031cdecfe..bc349096a 100644
--- a/packages/tui/internal/layout/overlay.go
+++ b/packages/tui/internal/layout/overlay.go
@@ -17,18 +17,15 @@ import (
// https://github.com/charmbracelet/lipgloss/pull/102
// 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
-// line.
+// Split a string into lines, additionally returning the size of the widest line.
func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n")
-
for _, l := range lines {
w := ansi.PrintableRuneWidth(l)
if widest < w {
widest = w
}
}
-
return lines, widest
}
@@ -49,7 +46,7 @@ func PlaceOverlay(
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
+ Background(t.BackgroundElement()).
Foreground(t.Background()).
Render("░")
bgchar := baseStyle.Render(" ")
diff --git a/packages/tui/internal/layout/split.go b/packages/tui/internal/layout/split.go
deleted file mode 100644
index 42b8deeab..000000000
--- a/packages/tui/internal/layout/split.go
+++ /dev/null
@@ -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
- }
-}
diff --git a/packages/tui/internal/page/chat.go b/packages/tui/internal/page/chat.go
index 8e175a26e..6c4227674 100644
--- a/packages/tui/internal/page/chat.go
+++ b/packages/tui/internal/page/chat.go
@@ -24,7 +24,7 @@ type chatPage struct {
app *app.App
editor layout.Container
messages layout.Container
- layout layout.SplitPaneLayout
+ layout layout.FlexLayout
completionDialog dialog.CompletionDialog
showCompletionDialog bool
}
@@ -96,12 +96,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil {
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:
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.Messages = []client.MessageInfo{}
return p, tea.Batch(
- p.clearSidebar(),
util.CmdHandler(state.SessionClearedMsg{}),
)
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)
cmds = append(cmds, cmd)
- p.layout = u.(layout.SplitPaneLayout)
+ p.layout = u.(layout.FlexLayout)
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 {
var cmds []tea.Cmd
cmd := p.app.SendChatMessage(context.Background(), text, attachments)
cmds = append(cmds, cmd)
- cmd = p.setSidebar()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
return tea.Batch(cmds...)
}
@@ -183,6 +160,7 @@ func (p *chatPage) GetSize() (int, int) {
func (p *chatPage) View() string {
layoutView := p.layout.View()
+ // TODO: Fix this with our new layout
if p.showCompletionDialog {
_, layoutHeight := p.layout.GetSize()
editorWidth, editorHeight := p.editor.GetSize()
@@ -213,21 +191,25 @@ func NewChatPage(app *app.App) tea.Model {
cg := completions.NewFileAndFolderContextGroup()
completionDialog := dialog.NewCompletionDialogCmp(cg)
messagesContainer := layout.NewContainer(
- chat.NewMessagesCmp(app),
- layout.WithPadding(1, 1, 0, 1),
+ chat.NewMessagesComponent(app),
)
editorContainer := layout.NewContainer(
- chat.NewEditorCmp(app),
- layout.WithBorder(true, false, false, false),
+ chat.NewEditorComponent(app),
+ layout.WithMaxWidth(layout.Current.Container.Width),
+ layout.WithAlignCenter(),
)
return &chatPage{
app: app,
editor: editorContainer,
messages: messagesContainer,
completionDialog: completionDialog,
- layout: layout.NewSplitPane(
- layout.WithLeftPanel(messagesContainer),
- layout.WithBottomPanel(editorContainer),
+ layout: layout.NewFlexLayout(
+ layout.WithPanes(messagesContainer, editorContainer),
+ layout.WithDirection(layout.FlexDirectionVertical),
+ layout.WithPaneSizes(
+ layout.FlexPaneSizeGrow,
+ layout.FlexPaneSizeFixed(6),
+ ),
),
}
}
diff --git a/packages/tui/internal/styles/styles.go b/packages/tui/internal/styles/styles.go
index f5dae7047..3916ea12c 100644
--- a/packages/tui/internal/styles/styles.go
+++ b/packages/tui/internal/styles/styles.go
@@ -13,23 +13,33 @@ func BaseStyle() lipgloss.Style {
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
func Regular() lipgloss.Style {
return lipgloss.NewStyle()
}
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
func Bold() lipgloss.Style {
- return Regular().Bold(true)
+ return BaseStyle().Bold(true)
}
// Padded returns a style with horizontal padding
func Padded() lipgloss.Style {
- return Regular().Padding(0, 1)
+ return BaseStyle().Padding(0, 1)
}
// Border returns a style with a normal border
@@ -37,7 +47,7 @@ func Border() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
- BorderForeground(t.BorderNormal())
+ BorderForeground(t.Border())
}
// ThickBorder returns a style with a thick border
@@ -45,7 +55,7 @@ func ThickBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.ThickBorder()).
- BorderForeground(t.BorderNormal())
+ BorderForeground(t.Border())
}
// DoubleBorder returns a style with a double border
@@ -53,7 +63,7 @@ func DoubleBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.DoubleBorder()).
- BorderForeground(t.BorderNormal())
+ BorderForeground(t.Border())
}
// FocusedBorder returns a style with a border using the focused border color
@@ -61,7 +71,7 @@ func FocusedBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
- BorderForeground(t.BorderFocused())
+ BorderForeground(t.BorderActive())
}
// DimBorder returns a style with a border using the dim border color
@@ -69,7 +79,7 @@ func DimBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
- BorderForeground(t.BorderDim())
+ BorderForeground(t.BorderSubtle())
}
// PrimaryColor returns the primary color from the current theme
@@ -117,37 +127,32 @@ func TextMutedColor() lipgloss.AdaptiveColor {
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
func BackgroundColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Background()
}
-// BackgroundSecondaryColor returns the secondary background color from the current theme
-func BackgroundSecondaryColor() lipgloss.AdaptiveColor {
- return theme.CurrentTheme().BackgroundSecondary()
+// BackgroundSubtleColor returns the subtle background color from the current theme
+func BackgroundSubtleColor() lipgloss.AdaptiveColor {
+ return theme.CurrentTheme().BackgroundSubtle()
}
-// BackgroundDarkerColor returns the darker background color from the current theme
-func BackgroundDarkerColor() lipgloss.AdaptiveColor {
- return theme.CurrentTheme().BackgroundDarker()
+// BackgroundElementColor returns the darker background color from the current theme
+func BackgroundElementColor() lipgloss.AdaptiveColor {
+ return theme.CurrentTheme().BackgroundElement()
}
-// BorderNormalColor returns the normal border color from the current theme
-func BorderNormalColor() lipgloss.AdaptiveColor {
- return theme.CurrentTheme().BorderNormal()
+// BorderColor returns the border color from the current theme
+func BorderColor() lipgloss.AdaptiveColor {
+ return theme.CurrentTheme().Border()
}
-// BorderFocusedColor returns the focused border color from the current theme
-func BorderFocusedColor() lipgloss.AdaptiveColor {
- return theme.CurrentTheme().BorderFocused()
+// BorderActiveColor returns the active border color from the current theme
+func BorderActiveColor() lipgloss.AdaptiveColor {
+ return theme.CurrentTheme().BorderActive()
}
-// BorderDimColor returns the dim border color from the current theme
-func BorderDimColor() lipgloss.AdaptiveColor {
- return theme.CurrentTheme().BorderDim()
+// BorderSubtleColor returns the subtle border color from the current theme
+func BorderSubtleColor() lipgloss.AdaptiveColor {
+ return theme.CurrentTheme().BorderSubtle()
}
diff --git a/packages/tui/internal/theme/ayu.go b/packages/tui/internal/theme/ayu.go
index b3dfa870a..947e41e5b 100644
--- a/packages/tui/internal/theme/ayu.go
+++ b/packages/tui/internal/theme/ayu.go
@@ -92,35 +92,31 @@ func NewAyuDarkTheme() *AyuDarkTheme {
Dark: darkComment,
Light: lightComment,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkPurple,
- Light: lightPurple,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#0b0e14", // Darker than background
Light: "#ffffff", // Lighter than background
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
diff --git a/packages/tui/internal/theme/catppuccin.go b/packages/tui/internal/theme/catppuccin.go
index c3c32501e..976eb7fbd 100644
--- a/packages/tui/internal/theme/catppuccin.go
+++ b/packages/tui/internal/theme/catppuccin.go
@@ -60,35 +60,31 @@ func NewCatppuccinTheme() *CatppuccinTheme {
Dark: mocha.Subtext0().Hex,
Light: latte.Subtext0().Hex,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: mocha.Lavender().Hex,
- Light: latte.Lavender().Hex,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: "#212121", // From existing styles
Light: "#EEEEEE", // Light equivalent
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: "#2c2c2c", // From existing styles
Light: "#E0E0E0", // Light equivalent
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#181818", // From existing styles
Light: "#F5F5F5", // Light equivalent
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: "#4b4c5c", // From existing styles
Light: "#BDBDBD", // Light equivalent
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: mocha.Surface0().Hex,
Light: latte.Surface0().Hex,
}
diff --git a/packages/tui/internal/theme/dracula.go b/packages/tui/internal/theme/dracula.go
index 29a1457d4..7f14d6290 100644
--- a/packages/tui/internal/theme/dracula.go
+++ b/packages/tui/internal/theme/dracula.go
@@ -86,35 +86,31 @@ func NewDraculaTheme() *DraculaTheme {
Dark: darkComment,
Light: lightComment,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
- }
// Background colors
- theme.BackgroundColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: "#21222c", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
@@ -133,8 +129,8 @@ func NewDraculaTheme() *DraculaTheme {
Light: lightComment,
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
- Dark: darkPurple,
- Light: lightPurple,
+ Dark: darkCurrentLine,
+ Light: lightCurrentLine,
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#50fa7b",
diff --git a/packages/tui/internal/theme/flexoki.go b/packages/tui/internal/theme/flexoki.go
index 5da5683c5..ad5443869 100644
--- a/packages/tui/internal/theme/flexoki.go
+++ b/packages/tui/internal/theme/flexoki.go
@@ -94,35 +94,31 @@ func NewFlexokiTheme() *FlexokiTheme {
Dark: flexokiBase700,
Light: flexokiBase500,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: flexokiYellow400,
- Light: flexokiYellow600,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: flexokiBlack,
Light: flexokiPaper,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: flexokiBase950,
Light: flexokiBase50,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900,
Light: flexokiBase100,
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900,
Light: flexokiBase100,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400,
Light: flexokiBlue600,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: flexokiBase850,
Light: flexokiBase150,
}
diff --git a/packages/tui/internal/theme/gruvbox.go b/packages/tui/internal/theme/gruvbox.go
index 51719faaa..da9aab50e 100644
--- a/packages/tui/internal/theme/gruvbox.go
+++ b/packages/tui/internal/theme/gruvbox.go
@@ -114,35 +114,31 @@ func NewGruvboxTheme() *GruvboxTheme {
Dark: gruvboxDarkFg4,
Light: gruvboxLightFg4,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: gruvboxDarkYellowBright,
- Light: gruvboxLightYellowBright,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0,
Light: gruvboxLightBg0,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0Soft,
Light: gruvboxLightBg0Soft,
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg2,
Light: gruvboxLightBg2,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1,
}
diff --git a/packages/tui/internal/theme/manager.go b/packages/tui/internal/theme/manager.go
index cb6c53991..020e3e193 100644
--- a/packages/tui/internal/theme/manager.go
+++ b/packages/tui/internal/theme/manager.go
@@ -157,20 +157,18 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
theme.TextColor = adaptiveColor
case "textmuted":
theme.TextMutedColor = adaptiveColor
- case "textemphasized":
- theme.TextEmphasizedColor = adaptiveColor
case "background":
theme.BackgroundColor = adaptiveColor
- case "backgroundsecondary":
- theme.BackgroundSecondaryColor = adaptiveColor
- case "backgrounddarker":
- theme.BackgroundDarkerColor = adaptiveColor
- case "bordernormal":
- theme.BorderNormalColor = adaptiveColor
- case "borderfocused":
- theme.BorderFocusedColor = adaptiveColor
- case "borderdim":
- theme.BorderDimColor = adaptiveColor
+ case "backgroundsubtle":
+ theme.BackgroundSubtleColor = adaptiveColor
+ case "backgroundelement":
+ theme.BackgroundElementColor = adaptiveColor
+ case "border":
+ theme.BorderColor = adaptiveColor
+ case "borderactive":
+ theme.BorderActiveColor = adaptiveColor
+ case "bordersubtle":
+ theme.BorderSubtleColor = adaptiveColor
case "diffadded":
theme.DiffAddedColor = adaptiveColor
case "diffremoved":
diff --git a/packages/tui/internal/theme/monokai.go b/packages/tui/internal/theme/monokai.go
index 7511d3333..71234d025 100644
--- a/packages/tui/internal/theme/monokai.go
+++ b/packages/tui/internal/theme/monokai.go
@@ -85,35 +85,31 @@ func NewMonokaiProTheme() *MonokaiProTheme {
Dark: darkComment,
Light: lightComment,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#221f22", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
diff --git a/packages/tui/internal/theme/onedark.go b/packages/tui/internal/theme/onedark.go
index a2c1447ca..21953c10d 100644
--- a/packages/tui/internal/theme/onedark.go
+++ b/packages/tui/internal/theme/onedark.go
@@ -86,35 +86,31 @@ func NewOneDarkTheme() *OneDarkTheme {
Dark: darkComment,
Light: lightComment,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#21252b", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
diff --git a/packages/tui/internal/theme/opencode.go b/packages/tui/internal/theme/opencode.go
index 7ee6f15e5..4de88ae4e 100644
--- a/packages/tui/internal/theme/opencode.go
+++ b/packages/tui/internal/theme/opencode.go
@@ -12,14 +12,23 @@ type OpenCodeTheme struct {
// NewOpenCodeTheme creates a new instance of the OpenCode theme.
func NewOpenCodeTheme() *OpenCodeTheme {
- // OpenCode color palette
- // Dark mode colors
- darkBackground := "#212121"
- darkCurrentLine := "#252525"
- darkSelection := "#303030"
- darkForeground := "#e0e0e0"
- darkComment := "#6a6a6a"
- darkPrimary := "#fab283" // Primary orange/gold
+ // OpenCode color palette with Radix-inspired scale progression
+ // Dark mode colors - using a neutral gray scale as base
+ darkStep1 := "#0a0a0a" // App background
+ darkStep2 := "#141414" // Subtle background
+ darkStep3 := "#1e1e1e" // UI element background
+ darkStep4 := "#282828" // Hovered UI element background
+ darkStep5 := "#323232" // Active/Selected UI element background
+ 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
darkAccent := "#9d7cd8" // Accent purple
darkRed := "#e06c75" // Error red
@@ -27,15 +36,23 @@ func NewOpenCodeTheme() *OpenCodeTheme {
darkGreen := "#7fd88f" // Success green
darkCyan := "#56b6c2" // Info cyan
darkYellow := "#e5c07b" // Emphasized text
- darkBorder := "#4b4c5c" // Border color
- // Light mode colors
- lightBackground := "#f8f8f8"
- lightCurrentLine := "#f0f0f0"
- lightSelection := "#e5e5e6"
- lightForeground := "#2a2a2a"
- lightComment := "#8a8a8a"
- lightPrimary := "#3b7dd8" // Primary blue
+ // Light mode colors - using a neutral gray scale as base
+ lightStep1 := "#ffffff" // App background
+ lightStep2 := "#fafafa" // Subtle background
+ lightStep3 := "#f5f5f5" // UI element background
+ lightStep4 := "#ebebeb" // Hovered UI element background
+ lightStep5 := "#e1e1e1" // Active/Selected UI element background
+ 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
lightAccent := "#d68c27" // Accent orange/gold
lightRed := "#d1383d" // Error red
@@ -43,7 +60,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
lightGreen := "#3d9a57" // Success green
lightCyan := "#318795" // Info cyan
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{}
@@ -81,44 +105,40 @@ func NewOpenCodeTheme() *OpenCodeTheme {
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
- }
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
+ Dark: darkStep11,
+ Light: lightStep11,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
- Dark: darkBackground,
- Light: lightBackground,
+ Dark: darkStep1,
+ Light: lightStep1,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
- Dark: darkCurrentLine,
- Light: lightCurrentLine,
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
+ Dark: darkStep2,
+ Light: lightStep2,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
- Dark: "#121212", // Slightly darker than background
- Light: "#ffffff", // Slightly lighter than background
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
+ Dark: darkStep3,
+ Light: lightStep3,
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
- Dark: darkBorder,
- Light: lightBorder,
+ theme.BorderColor = lipgloss.AdaptiveColor{
+ Dark: darkStep7,
+ Light: lightStep7,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
- Dark: darkPrimary,
- Light: lightPrimary,
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
+ Dark: darkStep8,
+ Light: lightStep8,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
- Dark: darkSelection,
- Light: lightSelection,
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
+ Dark: darkStep6,
+ Light: lightStep6,
}
// Diff view colors
@@ -155,12 +175,12 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: "#FFEBEE",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
- Dark: darkBackground,
- Light: lightBackground,
+ Dark: darkStep2,
+ Light: lightStep2,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
- Dark: "#888888",
- Light: "#9E9E9E",
+ Dark: darkStep3,
+ Light: lightStep3,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#293229",
@@ -173,8 +193,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkSecondary,
@@ -205,8 +225,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightAccent,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
+ Dark: darkStep11,
+ Light: lightStep11,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
@@ -225,14 +245,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
+ Dark: darkStep11,
+ Light: lightStep11,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkSecondary,
@@ -263,8 +283,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
return theme
diff --git a/packages/tui/internal/theme/theme.go b/packages/tui/internal/theme/theme.go
index c97b95478..8b2d926b1 100644
--- a/packages/tui/internal/theme/theme.go
+++ b/packages/tui/internal/theme/theme.go
@@ -11,32 +11,31 @@ import (
// All colors must be defined as lipgloss.AdaptiveColor to support
// both light and dark terminal backgrounds.
type Theme interface {
- // Base colors
- Primary() lipgloss.AdaptiveColor
+ // Background colors
+ 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
Accent() lipgloss.AdaptiveColor
+ // Text colors
+ TextMuted() lipgloss.AdaptiveColor // Radix 11
+ Text() lipgloss.AdaptiveColor // Radix 12
+
// Status colors
Error() lipgloss.AdaptiveColor
Warning() lipgloss.AdaptiveColor
Success() 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
DiffAdded() lipgloss.AdaptiveColor
DiffRemoved() lipgloss.AdaptiveColor
@@ -82,32 +81,31 @@ type Theme interface {
// BaseTheme provides a default implementation of the Theme interface
// that can be embedded in concrete theme implementations.
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
SecondaryColor lipgloss.AdaptiveColor
AccentColor lipgloss.AdaptiveColor
+ // Text colors
+ TextMutedColor lipgloss.AdaptiveColor
+ TextColor lipgloss.AdaptiveColor
+
// Status colors
ErrorColor lipgloss.AdaptiveColor
WarningColor lipgloss.AdaptiveColor
SuccessColor 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
DiffAddedColor 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) Info() lipgloss.AdaptiveColor { return t.InfoColor }
-func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor }
-func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
-func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor }
+func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor }
+func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
-func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor }
-func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor { return t.BackgroundDarkerColor }
+func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor }
+func (t *BaseTheme) BackgroundSubtle() lipgloss.AdaptiveColor { return t.BackgroundSubtleColor }
+func (t *BaseTheme) BackgroundElement() lipgloss.AdaptiveColor { return t.BackgroundElementColor }
-func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor { return t.BorderNormalColor }
-func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor }
-func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor { return t.BorderDimColor }
+func (t *BaseTheme) Border() lipgloss.AdaptiveColor { return t.BorderColor }
+func (t *BaseTheme) BorderActive() lipgloss.AdaptiveColor { return t.BorderActiveColor }
+func (t *BaseTheme) BorderSubtle() lipgloss.AdaptiveColor { return t.BorderSubtleColor }
func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor }
func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor }
diff --git a/packages/tui/internal/theme/theme_test.go b/packages/tui/internal/theme/theme_test.go
deleted file mode 100644
index 790ee3aa8..000000000
--- a/packages/tui/internal/theme/theme_test.go
+++ /dev/null
@@ -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)
-}
diff --git a/packages/tui/internal/theme/tokyonight.go b/packages/tui/internal/theme/tokyonight.go
index a6499a25d..7ae53abc5 100644
--- a/packages/tui/internal/theme/tokyonight.go
+++ b/packages/tui/internal/theme/tokyonight.go
@@ -12,36 +12,60 @@ type TokyoNightTheme struct {
// NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
func NewTokyoNightTheme() *TokyoNightTheme {
- // Tokyo Night color palette
- // Dark mode colors
- darkBackground := "#222436"
- darkCurrentLine := "#1e2030"
- darkSelection := "#2f334d"
- darkForeground := "#c8d3f5"
- darkComment := "#636da6"
+ // Tokyo Night color palette with Radix-inspired scale progression
+ // Dark mode colors - Tokyo Night Moon variant
+ darkStep1 := "#1a1b26" // App background (bg)
+ darkStep2 := "#1e2030" // Subtle background (bg_dark)
+ darkStep3 := "#222436" // UI element background (bg_highlight)
+ darkStep4 := "#292e42" // Hovered UI element background
+ 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"
darkOrange := "#ff966c"
darkYellow := "#ffc777"
darkGreen := "#c3e88d"
darkCyan := "#86e1fc"
- darkBlue := "#82aaff"
+ darkBlue := darkStep9 // Using step 9 for primary
darkPurple := "#c099ff"
- darkBorder := "#3b4261"
- // Light mode colors (Tokyo Night Day)
- lightBackground := "#e1e2e7"
- lightCurrentLine := "#d5d6db"
- lightSelection := "#c8c9ce"
- lightForeground := "#3760bf"
- lightComment := "#848cb5"
+ // Light mode colors - Tokyo Night Day variant
+ lightStep1 := "#e1e2e7" // App background
+ lightStep2 := "#d5d6db" // Subtle background
+ lightStep3 := "#c8c9ce" // UI element background
+ lightStep4 := "#b9bac1" // Hovered UI element background
+ 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"
lightOrange := "#b15c00"
lightYellow := "#8c6c3e"
lightGreen := "#587539"
lightCyan := "#007197"
- lightBlue := "#2e7de9"
+ lightBlue := lightStep9 // Using step 9 for primary
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{}
@@ -79,44 +103,40 @@ func NewTokyoNightTheme() *TokyoNightTheme {
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
- }
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
+ Dark: darkStep11,
+ Light: lightStep11,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
- Dark: darkBackground,
- Light: lightBackground,
+ Dark: darkStep1,
+ Light: lightStep1,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
- Dark: darkCurrentLine,
- Light: lightCurrentLine,
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
+ Dark: darkStep2,
+ Light: lightStep2,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
- Dark: "#191B29", // Darker background from palette
- Light: "#f0f0f5", // Slightly lighter than background
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
+ Dark: darkStep3,
+ Light: lightStep3,
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
- Dark: darkBorder,
- Light: lightBorder,
+ theme.BorderColor = lipgloss.AdaptiveColor{
+ Dark: darkStep7,
+ Light: lightStep7,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
- Dark: darkBlue,
- Light: lightBlue,
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
+ Dark: darkStep8,
+ Light: lightStep8,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
- Dark: darkSelection,
- Light: lightSelection,
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
+ Dark: darkStep6,
+ Light: lightStep6,
}
// Diff view colors
@@ -153,12 +173,12 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: "#f7d8db",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
- Dark: darkBackground,
- Light: lightBackground,
+ Dark: darkStep2,
+ Light: lightStep2,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
- Dark: "#545c7e", // dark3 from palette
- Light: "#848cb5",
+ Dark: darkStep3, // dark3 from palette
+ Light: lightStep3,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#1b2b34",
@@ -171,8 +191,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
@@ -203,8 +223,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
+ Dark: darkStep11,
+ Light: lightStep11,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
@@ -223,14 +243,14 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
- Dark: darkComment,
- Light: lightComment,
+ Dark: darkStep11,
+ Light: lightStep11,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
@@ -261,8 +281,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
- Dark: darkForeground,
- Light: lightForeground,
+ Dark: darkStep12,
+ Light: lightStep12,
}
return theme
diff --git a/packages/tui/internal/theme/tron.go b/packages/tui/internal/theme/tron.go
index c4997a6d1..ea9756753 100644
--- a/packages/tui/internal/theme/tron.go
+++ b/packages/tui/internal/theme/tron.go
@@ -88,35 +88,31 @@ func NewTronTheme() *TronTheme {
Dark: darkComment,
Light: lightComment,
}
- theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
- Dark: darkYellow,
- Light: lightYellow,
- }
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
- theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+ theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
- theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+ theme.BackgroundElementColor = lipgloss.AdaptiveColor{
Dark: "#070d14", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
- theme.BorderNormalColor = lipgloss.AdaptiveColor{
+ theme.BorderColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
- theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+ theme.BorderActiveColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
- theme.BorderDimColor = lipgloss.AdaptiveColor{
+ theme.BorderSubtleColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 776078b5f..e4fdb4ae2 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -158,7 +158,7 @@ func (a appModel) Init() tea.Cmd {
// Check if we should show the init dialog
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}
})
@@ -212,6 +212,27 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.Height -= 2 // Make space for the status bar
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)
a.status = s.(core.StatusCmp)
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -711,7 +732,6 @@ func (a appModel) View() string {
components := []string{
a.pages[a.currentPage].View(),
}
-
components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
@@ -943,7 +963,7 @@ func NewModel(app *app.App) tea.Model {
})
// Load custom commands
- customCommands, err := dialog.LoadCustomCommands(app)
+ customCommands, err := dialog.LoadCustomCommands()
if err != nil {
slog.Warn("Failed to load custom commands", "error", err)
} else {
diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go
index 207382d1e..fe2cf71e5 100644
--- a/packages/tui/internal/util/util.go
+++ b/packages/tui/internal/util/util.go
@@ -11,6 +11,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
}
func Clamp(v, low, high int) int {
+ // Swap if needed to ensure low <= high
if high < low {
low, high = high, low
}
diff --git a/packages/web/src/content/docs/docs/themes.mdx b/packages/web/src/content/docs/docs/themes.mdx
index a177ea3bc..1a2d8c2ee 100644
--- a/packages/web/src/content/docs/docs/themes.mdx
+++ b/packages/web/src/content/docs/docs/themes.mdx
@@ -59,9 +59,9 @@ 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` |
+| Text colors | `text`, `textMuted` |
+| Background colors | `background`, `backgroundSubtle`, `backgroundElement` |
+| Border colors | `border`, `borderActive`, `borderSubtle` |
| 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.