mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
wip: refactoring tui
This commit is contained in:
parent
979bad3e64
commit
95d5e1f231
37 changed files with 1496 additions and 1801 deletions
70
README.md
70
README.md
|
@ -411,76 +411,6 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
|
|||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
## Theming
|
||||
|
||||
OpenCode supports multiple themes for customizing the appearance of the terminal interface.
|
||||
|
||||
### Available Themes
|
||||
|
||||
The following predefined themes are available:
|
||||
|
||||
- `opencode` (default)
|
||||
- `catppuccin`
|
||||
- `dracula`
|
||||
- `flexoki`
|
||||
- `gruvbox`
|
||||
- `monokai`
|
||||
- `onedark`
|
||||
- `tokyonight`
|
||||
- `tron`
|
||||
- `custom` (user-defined)
|
||||
|
||||
### Setting a Theme
|
||||
|
||||
You can set a theme in your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"tui": {
|
||||
"theme": "monokai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Themes
|
||||
|
||||
You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
|
||||
|
||||
```json
|
||||
{
|
||||
"tui": {
|
||||
"theme": "custom",
|
||||
"customTheme": {
|
||||
"primary": "#ffcc00",
|
||||
"secondary": "#00ccff",
|
||||
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
|
||||
"error": "#ff0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Color Definition Formats
|
||||
|
||||
Custom theme colors support two formats:
|
||||
|
||||
1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
|
||||
|
||||
2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
|
||||
|
||||
#### Available Color Keys
|
||||
|
||||
You can define any of the following color keys in your `customTheme`:
|
||||
|
||||
- Base colors: `primary`, `secondary`, `accent`
|
||||
- Status colors: `error`, `warning`, `success`, `info`
|
||||
- Text colors: `text`, `textMuted`, `textEmphasized`
|
||||
- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
|
||||
- Border colors: `borderNormal`, `borderFocused`, `borderDim`
|
||||
- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
|
||||
|
||||
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
|
||||
|
||||
### Shell Configuration
|
||||
|
||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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 &&
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
248
packages/tui/internal/layout/flex.go
Normal file
248
packages/tui/internal/layout/flex.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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(" ")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue