wip: refactoring tui

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

View file

@ -411,76 +411,6 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `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:

View file

@ -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)
}

View file

@ -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,

View file

@ -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))
for _, param := range params {
h.Write(fmt.Appendf(nil, ":%v", param))
}
// Include tool metadata if present
for toolID, metadata := range msg.Metadata.Tool {
h.Write(fmt.Appendf(nil, ":%s:%v", toolID, metadata))
}
return hex.EncodeToString(h.Sum(nil))
}
// 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
}

View file

@ -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)
}

View file

@ -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 *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick)
}
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 *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")
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
model := ""
if m.app.Model != nil {
model = base(*m.app.Model.Name) + muted(" • /model")
}
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,
}
}

View file

@ -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")
borderColor := t.BackgroundSubtle()
if renderer.borderColor != nil {
borderColor = *renderer.borderColor
}
switch align {
case lipgloss.Left:
style = style.
BorderLeft(true).
BorderRight(true).
AlignHorizontal(align).
BorderLeftForeground(borderColor).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
case lipgloss.Right:
style = style.
BorderRight(true).
BorderLeft(true).
AlignHorizontal(align).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background()).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background())
}
content = styles.ForceReplaceBackgroundWithLipgloss(content, t.BackgroundSubtle())
if renderer.fullWidth {
style = style.Width(layout.Current.Container.Width - 2)
}
content = style.Render(content)
if renderer.paddingTop > 0 {
content = strings.Repeat("\n", renderer.paddingTop) + content
}
if renderer.paddingBottom > 0 {
content = content + strings.Repeat("\n", renderer.paddingBottom)
}
content = lipgloss.PlaceHorizontal(
layout.Current.Container.Width,
align,
content,
lipgloss.WithWhitespaceBackground(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceBackground(t.Background()),
)
return content
}
func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := 0
switch layout.Current.Size {
case layout.LayoutSizeSmall:
padding = 5
case layout.LayoutSizeNormal:
padding = 10
case layout.LayoutSizeLarge:
padding = 15
}
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
// don't show the date if it's today
timestamp = timestamp[12:]
}
info := styles.BaseStyle().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", user, timestamp))
Render(fmt.Sprintf("%s (%s)", author, 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))
}
align := lipgloss.Left
switch message.Role {
case client.User:
align = lipgloss.Right
case client.Assistant:
align = lipgloss.Left
}
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
textWidth := lipgloss.Width(text)
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth)
content = lipgloss.JoinVertical(align, content, info)
switch message.Role {
case client.User:
return renderMarkdown(content,
WithAlign(lipgloss.Right),
WithBorderColor(t.Secondary()),
)
case client.Assistant:
return renderMarkdown(content,
WithAlign(lipgloss.Left),
WithBorderColor(t.Primary()),
)
}
return ""
}
func renderAssistantMessage(
msg client.MessageInfo,
width int,
showToolMessages bool,
appInfo client.AppInfo,
func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
result *string,
metadata map[string]any,
showResult bool,
) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
PaddingLeft(1).
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
messages := []string{}
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
modelName := msg.Metadata.Assistant.ModelID
info := styles.BaseStyle().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartReasoning:
// reasoningPart := part.(client.MessagePartReasoning)
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
metadata := map[string]any{}
if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
}
message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
messages = append(messages, message)
}
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
}

View file

@ -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()),
))
}
m.viewport.Height = m.height - lipgloss.Height(m.header())
m.viewport.SetContent(strings.Join(centered, "\n"))
}
func (m *messagesCmp) View() string {
baseStyle := styles.BaseStyle()
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")
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
m.viewport.View(),
m.working(),
m.help(),
),
)
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())
}
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
return baseStyle.Width(m.width).Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.app, m.width),
"",
lspsConfigured(m.width),
),
// 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),
)
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,
}
}

View file

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

View file

@ -98,16 +98,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// 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 {
t := theme.CurrentTheme()
status := getHelpWidget()
if m.app.Session.Id == "" {
return styles.BaseStyle().
Width(m.width).
Height(2).
Render("")
}
t := theme.CurrentTheme()
logo := logo()
cwd := styles.Padded().
Foreground(t.TextMuted()).
Background(t.BackgroundSubtle()).
Render(app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" {
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())
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
modelName := m.model()
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
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
// 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())
// 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("")
// }
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.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
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 &&

View file

@ -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")

View file

@ -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

View file

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

View file

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

View file

@ -13,6 +13,8 @@ type Container interface {
Bindings
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)
}

View file

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

View file

@ -7,6 +7,35 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
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

View file

@ -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(" ")

View file

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

View file

@ -24,7 +24,7 @@ type chatPage struct {
app *app.App
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),
),
),
}
}

View file

@ -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()
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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",

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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":

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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

View file

@ -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 }

View file

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

View file

@ -12,36 +12,60 @@ type TokyoNightTheme struct {
// NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
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

View file

@ -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,
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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.