wip: refactoring tui

This commit is contained in:
adamdottv
2025-06-04 09:20:42 -05:00
parent 0b565b18c4
commit 01050a430f
60 changed files with 115 additions and 115 deletions

View File

@@ -0,0 +1,131 @@
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"
)
type SendMsg struct {
Text string
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()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
func cwd(app *app.App, width int) string {
cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(cwd)
}

View File

@@ -0,0 +1,406 @@
package chat
import (
"fmt"
"log/slog"
"os"
"os/exec"
"slices"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type editorCmp struct {
width int
height int
app *app.App
textarea textarea.Model
attachments []app.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type bluredEditorKeyMaps struct {
Send key.Binding
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter", "ctrl+s"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorCmp) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorCmp) Init() tea.Cmd {
return textarea.Blink
}
func (m *editorCmp) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
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:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
num := int(msg.Runes[0] - '0')
m.deleteMode = false
if num < 10 && len(m.attachments) > num {
if num == 0 {
m.attachments = m.attachments[num+1:]
} else {
m.attachments = slices.Delete(m.attachments, num, num+1)
}
return m, nil
}
}
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
// if m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID) {
// status.Warn("Agent is working, please wait...")
// return m, nil
// }
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
if key.Matches(msg, editorMaps.Paste) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, cmd
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, cmd
}
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *editorCmp) View() string {
t := theme.CurrentTheme()
// Style the prompt with theme colors
style := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
if len(m.attachments) == 0 {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
)
}
func (m *editorCmp) 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)
return nil
}
func (m *editorCmp) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height()
}
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
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 {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
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)
ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
return ta
}
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}

View File

@@ -0,0 +1,643 @@
package chat
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
maxResultHeight = 10
)
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
return strings.TrimSuffix(rendered, "\n")
}
func renderUserMessage(user string, msg client.MessageInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
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))
// }
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:]
}
info := styles.Padded().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", user, timestamp))
content := ""
// if len(styledAttachments) > 0 {
// attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
// content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
// } else {
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
}
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func convertToMap(input *any) (map[string]any, bool) {
if input == nil {
return nil, false // Handle nil pointer
}
value := *input // Dereference the pointer to get the interface value
m, ok := value.(map[string]any) // Type assertion
return m, ok
}
func renderAssistantMessage(
msg client.MessageInfo,
width int,
showToolMessages bool,
) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
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.Padded().
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, width)
messages = append(messages, message)
}
}
return strings.Join(messages, "\n\n")
}
func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
toolName := renderToolName(toolCall.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolCall.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
finished := result != nil
body := styles.Padded().Render("In progress...")
if finished {
body = *result
}
if toolCall.ToolName == "opencode_edit" {
filename := toolMap["filePath"].(string)
title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename))
// oldString := toolMap["oldString"].(string)
// newString := toolMap["newString"].(string)
if finished {
patch := metadata["diff"].(string)
formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
body = strings.TrimSpace(formattedDiff)
}
return style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
} else if toolCall.ToolName == "opencode_view" {
filename := toolMap["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)
}
content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
// Default rendering
if finished {
body = styles.Padded().Render(truncateHeight(strings.TrimSpace(body), 10))
body = toMarkdown(body, width)
}
content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
return "List"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Title(language.Und).String(normalizedName)
}
}
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
return "Building command..."
case "opencode_edit":
return "Preparing edit..."
case "opencode_fetch":
return "Writing fetch..."
case "opencode_glob":
return "Finding files..."
case "opencode_grep":
return "Searching content..."
case "opencode_ls":
return "Listing directory..."
case "opencode_view":
return "Reading file..."
case "opencode_write":
return "Preparing write..."
case "opencode_patch":
return "Preparing patch..."
case "opencode_batch":
return "Running batch operations..."
}
return "Working..."
}
// renders params, params[0] (params[1]=params[2] ....)
func renderParams(paramsWidth int, params ...string) string {
if len(params) == 0 {
return ""
}
mainParam := params[0]
if len(mainParam) > paramsWidth {
mainParam = mainParam[:paramsWidth-3] + "..."
}
if len(params) == 1 {
return mainParam
}
otherParams := params[1:]
// create pairs of key/value
// if odd number of params, the last one is a key without value
if len(otherParams)%2 != 0 {
otherParams = append(otherParams, "")
}
parts := make([]string, 0, len(otherParams)/2)
for i := 0; i < len(otherParams); i += 2 {
key := otherParams[i]
value := otherParams[i+1]
if value == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s=%s", key, value))
}
partsRendered := strings.Join(parts, ", ")
remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
if remainingWidth < 30 {
// No space for the params, just show the main
return mainParam
}
if len(parts) > 0 {
mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
}
return ansi.Truncate(mainParam, paramsWidth, "...")
}
func renderToolParams(paramWidth int, toolCall any) string {
params := ""
switch toolCall {
// // case agent.AgentToolName:
// // var params agent.AgentParams
// // json.Unmarshal([]byte(toolCall.Input), &params)
// // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
// // return renderParams(paramWidth, prompt)
// case "bash":
// var params tools.BashParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// command := strings.ReplaceAll(params.Command, "\n", " ")
// return renderParams(paramWidth, command)
// case "edit":
// var params tools.EditParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case "fetch":
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// url := params.URL
// toolParams := []string{
// url,
// }
// if params.Format != "" {
// toolParams = append(toolParams, "format", params.Format)
// }
// if params.Timeout != 0 {
// toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GlobToolName:
// var params tools.GlobParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GrepToolName:
// var params tools.GrepParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// if params.Include != "" {
// toolParams = append(toolParams, "include", params.Include)
// }
// if params.LiteralText {
// toolParams = append(toolParams, "literal", "true")
// }
// return renderParams(paramWidth, toolParams...)
// case tools.LSToolName:
// var params tools.LSParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// path := params.Path
// if path == "" {
// path = "."
// }
// return renderParams(paramWidth, path)
// case tools.ViewToolName:
// var params tools.ViewParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// toolParams := []string{
// filePath,
// }
// if params.Limit != 0 {
// toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
// }
// if params.Offset != 0 {
// toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
// }
// return renderParams(paramWidth, toolParams...)
// case tools.WriteToolName:
// var params tools.WriteParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case tools.BatchToolName:
// var params tools.BatchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
// default:
// input := strings.ReplaceAll(toolCall, "\n", " ")
// params = renderParams(paramWidth, input)
}
return params
}
func truncateHeight(content string, height int) string {
lines := strings.Split(content, "\n")
if len(lines) > height {
return strings.Join(lines[:height], "\n")
}
return content
}
func renderToolResponse(toolCall any, response any, width int) string {
return ""
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if response.IsError {
// errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
// errContent = ansi.Truncate(errContent, width-1, "...")
// return baseStyle.
// Width(width).
// Foreground(t.Error()).
// Render(errContent)
// }
//
// resultContent := truncateHeight(response.Content, maxResultHeight)
// switch toolCall.Name {
// case agent.AgentToolName:
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, false, width),
// t.Background(),
// )
// case tools.BashToolName:
// resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.EditToolName:
// metadata := tools.EditResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
// return formattedDiff
// case tools.FetchToolName:
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// mdFormat := "markdown"
// switch params.Format {
// case "text":
// mdFormat = "text"
// case "html":
// mdFormat = "html"
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.GlobToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.GrepToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.LSToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.ViewToolName:
// metadata := tools.ViewResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(metadata.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.WriteToolName:
// params := tools.WriteParams{}
// json.Unmarshal([]byte(toolCall.Input), &params)
// metadata := tools.WriteResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(params.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.BatchToolName:
// var batchResult tools.BatchResult
// if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
// return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
// }
//
// var toolCalls []string
// for i, result := range batchResult.Results {
// toolName := renderToolName(result.ToolName)
//
// // Format the tool input as a string
// inputStr := string(result.ToolInput)
//
// // Format the result
// var resultStr string
// if result.Error != "" {
// resultStr = fmt.Sprintf("Error: %s", result.Error)
// } else {
// var toolResponse tools.ToolResponse
// if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
// resultStr = "Error parsing tool response"
// } else {
// resultStr = truncateHeight(toolResponse.Content, 3)
// }
// }
//
// // Format the tool call
// toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
// toolCalls = append(toolCalls, toolCall)
// }
//
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
// default:
// resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// }
}
// func renderToolMessage(
// toolCall message.ToolCall,
// allMessages []message.Message,
// messagesService message.Service,
// focusedUIMessageId string,
// nested bool,
// width int,
// position int,
// ) string {
// if nested {
// width = width - 3
// }
//
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// style := baseStyle.
// Width(width - 1).
// BorderLeft(true).
// BorderStyle(lipgloss.ThickBorder()).
// PaddingLeft(1).
// BorderForeground(t.TextMuted())
//
// response := findToolResponse(toolCall.ID, allMessages)
// toolNameText := baseStyle.Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s: ", renderToolName(toolCall.Name)))
//
// if !toolCall.Finished {
// // Get a brief description of what the tool is doing
// toolAction := renderToolAction(toolCall.Name)
//
// progressText := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s", toolAction))
//
// content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
// return content
// }
//
// params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
// responseContent := ""
// if response != nil {
// responseContent = renderToolResponse(toolCall, *response, width-2)
// responseContent = strings.TrimSuffix(responseContent, "\n")
// } else {
// responseContent = baseStyle.
// Italic(true).
// Width(width - 2).
// Foreground(t.TextMuted()).
// Render("Waiting for response...")
// }
//
// parts := []string{}
// if !nested {
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
//
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
// } else {
// prefix := baseStyle.
// Foreground(t.TextMuted()).
// Render(" └ ")
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
// }
//
// // if toolCall.Name == agent.AgentToolName {
// // taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
// // toolCalls := []message.ToolCall{}
// // for _, v := range taskMessages {
// // toolCalls = append(toolCalls, v.ToolCalls()...)
// // }
// // for _, call := range toolCalls {
// // rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
// // parts = append(parts, rendered.content)
// // }
// // }
// if responseContent != "" && !nested {
// parts = append(parts, responseContent)
// }
//
// content := style.Render(
// lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// ),
// )
// if nested {
// content = lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// )
// }
// return content
// }

View File

@@ -0,0 +1,344 @@
package chat
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"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
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
func (m *messagesCmp) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages
m.renderView()
return m, nil
case state.SessionSelectedMsg:
cmd := m.Reload()
return m, cmd
case state.SessionClearedMsg:
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) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
m.viewport.GotoBottom()
case state.StateUpdatedMsg:
m.renderView()
m.viewport.GotoBottom()
}
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *messagesCmp) renderView() {
if m.width == 0 {
return
}
messages := make([]string, 0)
for _, msg := range m.app.Messages {
switch msg.Role {
case client.User:
content := renderUserMessage(m.app.Info.User, msg, m.width)
messages = append(messages, content+"\n")
case client.Assistant:
content := renderAssistantMessage(msg, m.width, m.showToolMessages)
messages = append(messages, content+"\n")
}
}
m.viewport.SetContent(
styles.BaseStyle().
Render(
lipgloss.JoinVertical(
lipgloss.Top,
messages...,
),
),
)
}
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(),
),
)
}
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(),
),
)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
m.viewport.View(),
m.working(),
m.help(),
),
)
}
// func hasToolsWithoutResponse(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// toolResults := make([]message.ToolResult, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// toolResults = append(toolResults, m.ToolResults()...)
// }
//
// for _, v := range toolCalls {
// found := false
// for _, r := range toolResults {
// if v.ID == r.ToolCallID {
// found = true
// break
// }
// }
// if !found && v.Finished {
// return true
// }
// }
// return false
// }
// func hasUnfinishedToolCalls(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// }
// for _, v := range toolCalls {
// if !v.Finished {
// return true
// }
// }
// return false
// }
func (m *messagesCmp) working() string {
text := ""
if len(m.app.Messages) > 0 {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
task := ""
lastMessage := m.app.Messages[len(m.app.Messages)-1]
if lastMessage.Metadata.Time.Completed == nil {
task = "Working..."
}
// lastMessage := m.app.Messages[len(m.app.Messages)-1]
// if hasToolsWithoutResponse(m.app.Messages) {
// task = "Waiting for tool response..."
// } else if hasUnfinishedToolCalls(m.app.Messages) {
// task = "Building tool call..."
// } else if !lastMessage.IsFinished() {
// task = "Generating..."
// }
if task != "" {
text += baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
}
}
return text
}
func (m *messagesCmp) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
text := ""
// if m.app.PrimaryAgentOLD.IsBusy() {
// text += lipgloss.JoinHorizontal(
// lipgloss.Left,
// baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
// baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
// )
// } else {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
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"),
)
// }
return baseStyle.
Width(m.width).
Render(text)
}
func (m *messagesCmp) initialScreen() string {
baseStyle := styles.BaseStyle()
return baseStyle.Width(m.width).Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.app, m.width),
"",
lspsConfigured(m.width),
),
)
}
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
}
m.width = width
m.height = height
m.viewport.Width = width
m.viewport.Height = height - 2
m.attachments.Width = width + 40
m.attachments.Height = 3
m.renderView()
return nil
}
func (m *messagesCmp) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesCmp) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
m.renderView()
return renderFinishedMsg{}
}
}
func (m *messagesCmp) BindingKeys() []key.Binding {
return []key.Binding{
m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp,
m.viewport.KeyMap.HalfPageUp,
m.viewport.KeyMap.HalfPageDown,
}
}
func NewMessagesCmp(app *app.App) tea.Model {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New(0, 0)
attachments := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolMessages: true,
}
}

View File

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

View File

@@ -0,0 +1,351 @@
package core
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type StatusCmp interface {
tea.Model
}
type statusCmp struct {
app *app.App
queue []status.StatusMessage
width int
messageTTL time.Duration
activeUntil time.Time
}
// clearMessageCmd is a command that clears status messages after a timeout
func (m statusCmp) clearMessageCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return statusCleanupMsg{time: t}
})
}
// statusCleanupMsg is a message that triggers cleanup of expired status messages
type statusCleanupMsg struct {
time time.Time
}
func (m statusCmp) Init() tea.Cmd {
return m.clearMessageCmd()
}
func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case pubsub.Event[status.StatusMessage]:
if msg.Type == status.EventStatusPublished {
// If this is a critical message, move it to the front of the queue
if msg.Payload.Critical {
// Insert at the front of the queue
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
// Reset active time to show critical message immediately
m.activeUntil = time.Time{}
} else {
// Otherwise, just add it to the queue
m.queue = append(m.queue, msg.Payload)
// If this is the first message and nothing is active, activate it immediately
if len(m.queue) == 1 && m.activeUntil.IsZero() {
now := time.Now()
duration := m.messageTTL
if msg.Payload.Duration > 0 {
duration = msg.Payload.Duration
}
m.activeUntil = now.Add(duration)
}
}
}
case statusCleanupMsg:
now := msg.time
// If the active message has expired, remove it and activate the next one
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
// Current message expired, remove it if we have one
if len(m.queue) > 0 {
m.queue = m.queue[1:]
}
m.activeUntil = time.Time{}
}
// If we have messages in queue but none are active, activate the first one
if len(m.queue) > 0 && m.activeUntil.IsZero() {
// Use custom duration if specified, otherwise use default
duration := m.messageTTL
if m.queue[0].Duration > 0 {
duration = m.queue[0].Duration
}
m.activeUntil = now.Add(duration)
}
return m, m.clearMessageCmd()
}
return m, nil
}
// getHelpWidget returns the help widget with current theme colors
func getHelpWidget() string {
t := theme.CurrentTheme()
helpText := "ctrl+? help"
return styles.Padded().
Background(t.TextMuted()).
Foreground(t.BackgroundDarker()).
Bold(true).
Render(helpText)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
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 != "" {
tokens := float32(0)
cost := float32(0)
contextWindow := m.app.Model.ContextWindow
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
tokens = (usage.Input + usage.Output + usage.Reasoning)
}
}
}
tokensInfo := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
status += tokensInfo
}
diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
modelName := m.model()
statusWidth := max(
0,
m.width-
lipgloss.Width(status)-
lipgloss.Width(modelName)-
lipgloss.Width(diagnostics),
)
const minInlineWidth = 30
// Display the first status message if available
var statusMessage string
if len(m.queue) > 0 {
sm := m.queue[0]
infoStyle := styles.Padded().
Foreground(t.Background())
switch sm.Level {
case "info":
infoStyle = infoStyle.Background(t.Info())
case "warn":
infoStyle = infoStyle.Background(t.Warning())
case "error":
infoStyle = infoStyle.Background(t.Error())
case "debug":
infoStyle = infoStyle.Background(t.TextMuted())
}
// Truncate message if it's longer than available width
msg := sm.Message
availWidth := statusWidth - 10
// If we have enough space, show inline
if availWidth >= minInlineWidth {
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
status += infoStyle.Width(statusWidth).Render(msg)
} else {
// Otherwise, prepare a full-width message to show above
if len(msg) > m.width-10 && m.width > 10 {
msg = msg[:m.width-10] + "..."
}
statusMessage = infoStyle.Width(m.width).Render(msg)
// Add empty space in the status bar
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
status += diagnostics
status += modelName
// If we have a separate status message, prepend it
if 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 {
t := theme.CurrentTheme()
// Check if any LSP server is still initializing
initializing := false
// for _, client := range m.app.LSPClients {
// if client.GetServerState() == lsp.StateStarting {
// initializing = true
// break
// }
// }
// If any server is initializing, show that status
if initializing {
return lipgloss.NewStyle().
Foreground(t.Warning()).
Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
}
// errorDiagnostics := []protocol.Diagnostic{}
// warnDiagnostics := []protocol.Diagnostic{}
// hintDiagnostics := []protocol.Diagnostic{}
// infoDiagnostics := []protocol.Diagnostic{}
// for _, client := range m.app.LSPClients {
// for _, d := range client.GetDiagnostics() {
// for _, diag := range d {
// switch diag.Severity {
// case protocol.SeverityError:
// errorDiagnostics = append(errorDiagnostics, diag)
// case protocol.SeverityWarning:
// warnDiagnostics = append(warnDiagnostics, diag)
// case protocol.SeverityHint:
// hintDiagnostics = append(hintDiagnostics, diag)
// case protocol.SeverityInformation:
// infoDiagnostics = append(infoDiagnostics, diag)
// }
// }
// }
// }
return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render("No diagnostics"),
t.BackgroundDarker(),
)
// if len(errorDiagnostics) == 0 &&
// len(warnDiagnostics) == 0 &&
// len(infoDiagnostics) == 0 &&
// len(hintDiagnostics) == 0 {
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render("No diagnostics"),
// t.BackgroundDarker(),
// )
// }
// diagnostics := []string{}
//
// errStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Error()).
// Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
// diagnostics = append(diagnostics, errStr)
//
// warnStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Warning()).
// Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
// diagnostics = append(diagnostics, warnStr)
//
// infoStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Info()).
// Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
// diagnostics = append(diagnostics, infoStr)
//
// hintStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Text()).
// Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
// diagnostics = append(diagnostics, hintStr)
//
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render(strings.Join(diagnostics, " ")),
// t.BackgroundDarker(),
// )
}
func (m statusCmp) model() string {
t := theme.CurrentTheme()
model := "None"
if m.app.Model != nil {
model = *m.app.Model.Name
}
return styles.Padded().
Background(t.Secondary()).
Foreground(t.Background()).
Render(model)
}
func NewStatusCmp(app *app.App) StatusCmp {
statusComponent := &statusCmp{
app: app,
queue: []status.StatusMessage{},
messageTTL: 4 * time.Second,
activeUntil: time.Time{},
}
return statusComponent
}

View File

@@ -0,0 +1,257 @@
package dialog
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type argumentsDialogKeyMap struct {
Enter key.Binding
Escape key.Binding
}
// ShortHelp implements key.Map.
func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// FullHelp implements key.Map.
func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
type ShowMultiArgumentsDialogMsg struct {
CommandID string
Content string
ArgNames []string
}
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
type CloseMultiArgumentsDialogMsg struct {
Submit bool
CommandID string
Content string
Args map[string]string
}
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
type MultiArgumentsDialogCmp struct {
width, height int
inputs []textinput.Model
focusIndex int
keys argumentsDialogKeyMap
commandID string
content string
argNames []string
}
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
t := theme.CurrentTheme()
inputs := make([]textinput.Model, len(argNames))
for i, name := range argNames {
ti := textinput.New()
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
ti.Width = 40
ti.Prompt = ""
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
ti.TextStyle = ti.TextStyle.Background(t.Background())
// Only focus the first input initially
if i == 0 {
ti.Focus()
ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
} else {
ti.Blur()
}
inputs[i] = ti
}
return MultiArgumentsDialogCmp{
inputs: inputs,
keys: argumentsDialogKeyMap{},
commandID: commandID,
content: content,
argNames: argNames,
focusIndex: 0,
}
}
// Init implements tea.Model.
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
// Make sure only the first input is focused
for i := range m.inputs {
if i == 0 {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return textinput.Blink
}
// Update implements tea.Model.
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
t := theme.CurrentTheme()
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: false,
CommandID: m.commandID,
Content: m.content,
Args: nil,
})
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
// If we're on the last input, submit the form
if m.focusIndex == len(m.inputs)-1 {
args := make(map[string]string)
for i, name := range m.argNames {
args[name] = m.inputs[i].Value()
}
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: true,
CommandID: m.commandID,
Content: m.content,
Args: args,
})
}
// Otherwise, move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex++
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
// Move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
// Move to the previous input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// Update the focused input
var cmd tea.Cmd
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View implements tea.Model.
func (m MultiArgumentsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("Command Arguments")
explanation := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("This command requires multiple arguments. Please enter values for each:")
// Create input fields for each argument
inputFields := make([]string, len(m.inputs))
for i, input := range m.inputs {
// Highlight the label of the focused input
labelStyle := lipgloss.NewStyle().
Width(maxWidth).
Padding(1, 1, 0, 1).
Background(t.Background())
if i == m.focusIndex {
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
} else {
labelStyle = labelStyle.Foreground(t.TextMuted())
}
label := labelStyle.Render(m.argNames[i] + ":")
field := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render(input.View())
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
}
maxWidth = min(maxWidth, m.width-10)
// Join all elements vertically
elements := []string{title, explanation}
elements = append(elements, inputFields...)
content := lipgloss.JoinVertical(
lipgloss.Left,
elements...,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}

View File

@@ -0,0 +1,180 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// Command represents a command that can be executed
type Command struct {
ID string
Title string
Description string
Handler func(cmd Command) tea.Cmd
}
func (ci Command) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
itemStyle := baseStyle.Width(width).
Foreground(t.Text()).
Background(t.Background())
if selected {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(ci.Title)
if ci.Description != "" {
description := descStyle.Padding(0, 1).Render(ci.Description)
return lipgloss.JoinVertical(lipgloss.Left, title, description)
}
return title
}
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
}
// CloseCommandDialogMsg is sent when the command dialog is closed
type CloseCommandDialogMsg struct{}
// CommandDialog interface for the command selection dialog
type CommandDialog interface {
tea.Model
layout.Bindings
SetCommands(commands []Command)
}
type commandDialogCmp struct {
listView utilComponents.SimpleList[Command]
width int
height int
}
type commandKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var commandKeys = commandKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (c *commandDialogCmp) Init() tea.Cmd {
return c.listView.Init()
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, commandKeys.Enter):
selectedItem, idx := c.listView.GetSelectedItem()
if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
return c, util.CmdHandler(CloseCommandDialogMsg{})
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[Command])
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
commands := c.listView.GetItems()
for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4
}
if cmd.Description != "" {
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
}
c.listView.SetMaxWidth(maxWidth)
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Commands")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (c *commandDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(commandKeys)
}
func (c *commandDialogCmp) SetCommands(commands []Command) {
c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
listView := utilComponents.NewSimpleList[Command](
[]Command{},
10,
"No commands available",
true,
)
return &commandDialogCmp{
listView: listView,
}
}

View File

@@ -0,0 +1,263 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type CompletionItem struct {
title string
Title string
Value string
}
type CompletionItemI interface {
utilComponents.SimpleListItem
GetValue() string
DisplayValue() string
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
itemStyle := baseStyle.
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()).
Bold(true)
}
title := itemStyle.Render(
ci.GetValue(),
)
return title
}
func (ci *CompletionItem) DisplayValue() string {
return ci.Title
}
func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
}
type CompletionDialogCompleteItemMsg struct {
Value string
}
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
tea.Model
layout.Bindings
SetWidth(width int)
}
type completionDialogCmp struct {
query string
completionProvider CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
listView utilComponents.SimpleList[CompletionItemI]
}
type completionDialogKeyMap struct {
Complete key.Binding
Cancel key.Binding
}
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
),
}
func (c *completionDialogCmp) Init() tea.Cmd {
return nil
}
func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
}),
c.close(),
)
}
func (c *completionDialogCmp) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.query = query
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem()
if i == -1 {
return c, nil
}
cmd := c.complete(item)
return c, cmd
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, tea.Batch(cmds...)
}
func (c *completionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
completions := c.listView.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
}
}
c.listView.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()).
BorderBottom(false).
BorderRight(false).
BorderLeft(false).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(c.width).
Render(c.listView.View())
}
func (c *completionDialogCmp) SetWidth(width int) {
c.width = width
}
func (c *completionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(completionDialogKeys)
}
func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
items, err := completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
items,
7,
"No file matches found",
false,
)
return &completionDialogCmp{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
}
}

View File

@@ -0,0 +1,155 @@
package dialog
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/util"
)
// Command prefix constants
const (
UserCommandPrefix = "user:"
ProjectCommandPrefix = "project:"
)
// namedArgPattern is a regex pattern to find named arguments in the format $NAME
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) {
var commands []Command
homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load home commands: %v\n", err)
} else {
commands = append(commands, homeCommands...)
}
projectCommandsDir := filepath.Join(app.Info.Path.Root, ".opencode", "commands")
projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
if err != nil {
// Log error but return what we have so far
fmt.Printf("Warning: failed to load project commands: %v\n", err)
} else {
commands = append(commands, projectCommands...)
}
return commands, nil
}
// loadCommandsFromDir loads commands from a specific directory with the given prefix
func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
// Check if the commands directory exists
if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
// Create the commands directory if it doesn't exist
if err := os.MkdirAll(commandsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
}
// Return empty list since we just created the directory
return []Command{}, nil
}
var commands []Command
// Walk through the commands directory and load all .md files
err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
// Only process markdown files
if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
return nil
}
// Read the file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read command file %s: %w", path, err)
}
// Get the command ID from the file name without the .md extension
commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
// Get relative path from commands directory
relPath, err := filepath.Rel(commandsDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
// Create the command ID from the relative path
// Replace directory separators with colons
commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
if commandIDPath != "." {
commandID = commandIDPath + ":" + commandID
}
// Create a command
command := Command{
ID: prefix + commandID,
Title: prefix + commandID,
Description: fmt.Sprintf("Custom command from %s", relPath),
Handler: func(cmd Command) tea.Cmd {
commandContent := string(content)
// Check for named arguments
matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
if len(matches) > 0 {
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Show multi-arguments dialog for all named arguments
return util.CmdHandler(ShowMultiArgumentsDialogMsg{
CommandID: cmd.ID,
Content: commandContent,
ArgNames: argNames,
})
}
// No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent,
Args: nil, // No arguments
})
},
}
commands = append(commands, command)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
}
return commands, nil
}
// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
Args map[string]string // Map of argument names to values
}

View File

@@ -0,0 +1,106 @@
package dialog
import (
"testing"
"regexp"
)
func TestNamedArgPattern(t *testing.T) {
testCases := []struct {
input string
expected []string
}{
{
input: "This is a test with $ARGUMENTS placeholder",
expected: []string{"ARGUMENTS"},
},
{
input: "This is a test with $FOO and $BAR placeholders",
expected: []string{"FOO", "BAR"},
},
{
input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
expected: []string{"FOO_BAR", "BAZ123"},
},
{
input: "This is a test with no placeholders",
expected: []string{},
},
{
input: "This is a test with $FOO appearing twice: $FOO",
expected: []string{"FOO"},
},
{
input: "This is a test with $1INVALID placeholder",
expected: []string{},
},
}
for _, tc := range testCases {
matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Check if we got the expected number of arguments
if len(argNames) != len(tc.expected) {
t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
continue
}
// Check if we got the expected argument names
for _, expectedArg := range tc.expected {
found := false
for _, actualArg := range argNames {
if actualArg == expectedArg {
found = true
break
}
}
if !found {
t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
}
}
}
}
func TestRegexPattern(t *testing.T) {
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
validMatches := []string{
"$FOO",
"$BAR",
"$FOO_BAR",
"$BAZ123",
"$ARGUMENTS",
}
invalidMatches := []string{
"$foo",
"$1BAR",
"$_FOO",
"FOO",
"$",
}
for _, valid := range validMatches {
if !pattern.MatchString(valid) {
t.Errorf("Expected %s to match, but it didn't", valid)
}
}
for _, invalid := range invalidMatches {
if pattern.MatchString(invalid) {
t.Errorf("Expected %s not to match, but it did", invalid)
}
}
}

View File

@@ -0,0 +1,485 @@
package dialog
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const (
maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
downArrow = "down"
upArrow = "up"
)
type FilePrickerKeyMap struct {
Enter key.Binding
Down key.Binding
Up key.Binding
Forward key.Binding
Backward key.Binding
OpenFilePicker key.Binding
Esc key.Binding
InsertCWD key.Binding
Paste key.Binding
}
var filePickerKeyMap = FilePrickerKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select file/enter directory"),
),
Down: key.NewBinding(
key.WithKeys("j", downArrow),
key.WithHelp("↓/j", "down"),
),
Up: key.NewBinding(
key.WithKeys("k", upArrow),
key.WithHelp("↑/k", "up"),
),
Forward: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "enter directory"),
),
Backward: key.NewBinding(
key.WithKeys("h", "backspace"),
key.WithHelp("h/backspace", "go back"),
),
OpenFilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "open file picker"),
),
Esc: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close/exit"),
),
InsertCWD: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "manual path input"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste file/directory path"),
),
}
type filepickerCmp struct {
basePath string
width int
height int
cursor int
err error
cursorChain stack
viewport viewport.Model
dirs []os.DirEntry
cwdDetails *DirNode
selectedFile string
cwd textinput.Model
ShowFilePicker bool
app *app.App
}
type DirNode struct {
parent *DirNode
child *DirNode
directory string
}
type stack []int
func (s stack) Push(v int) stack {
return append(s, v)
}
func (s stack) Pop() (stack, int) {
l := len(s)
return s[:l-1], s[l-1]
}
type AttachmentAddedMsg struct {
Attachment app.Attachment
}
func (f *filepickerCmp) Init() tea.Cmd {
return nil
}
func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = 60
f.height = 20
f.viewport.Width = 80
f.viewport.Height = 22
f.cursor = 0
f.getCurrentFileBelowCursor()
case tea.KeyMsg:
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
switch {
case key.Matches(msg, filePickerKeyMap.InsertCWD):
f.cwd.Focus()
return f, cmd
case key.Matches(msg, filePickerKeyMap.Esc):
if f.cwd.Focused() {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Down):
if !f.cwd.Focused() || msg.String() == downArrow {
if f.cursor < len(f.dirs)-1 {
f.cursor++
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Up):
if !f.cwd.Focused() || msg.String() == upArrow {
if f.cursor > 0 {
f.cursor--
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Enter):
var path string
var isPathDir bool
if f.cwd.Focused() {
path = f.cwd.Value()
fileInfo, err := os.Stat(path)
if err != nil {
status.Error("Invalid path")
return f, cmd
}
isPathDir = fileInfo.IsDir()
} else {
path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
isPathDir = f.dirs[f.cursor].IsDir()
}
if isPathDir {
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
} else {
f.selectedFile = path
return f.addAttachmentToMessage()
}
case key.Matches(msg, filePickerKeyMap.Esc):
if !f.cwd.Focused() {
f.cursorChain = make(stack, 0)
f.cursor = 0
} else {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Forward):
if !f.cwd.Focused() {
if f.dirs[f.cursor].IsDir() {
path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Backward):
if !f.cwd.Focused() {
if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
f.cursorChain, f.cursor = f.cursorChain.Pop()
f.cwdDetails = f.cwdDetails.parent
f.cwdDetails.child = nil
f.dirs = readDir(f.cwdDetails.directory, false)
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Paste):
if f.cwd.Focused() {
val, err := clipboard.ReadAll()
if err != nil {
slog.Error("failed to read clipboard")
return f, cmd
}
f.cwd.SetValue(f.cwd.Value() + val)
}
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.getCurrentFileBelowCursor()
}
}
return f, cmd
}
func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
// modeInfo := GetSelectedModel(config.Get())
// if !modeInfo.SupportsAttachments {
// status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
// return f, nil
// }
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
status.Error("Unsupported file")
return f, nil
}
isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
if err != nil {
status.Error("unable to read the image")
return f, nil
}
if isFileLarge {
status.Error("file too large, max 5MB")
return f, nil
}
content, err := os.ReadFile(selectedFilePath)
if err != nil {
status.Error("Unable read selected file")
return f, nil
}
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
fileName := filepath.Base(selectedFilePath)
attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
f.selectedFile = ""
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
func (f *filepickerCmp) View() string {
t := theme.CurrentTheme()
const maxVisibleDirs = 20
const maxWidth = 80
adjustedWidth := maxWidth
for _, file := range f.dirs {
if len(file.Name()) > adjustedWidth-4 { // Account for padding
adjustedWidth = len(file.Name()) + 4
}
}
adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
files := make([]string, 0, maxVisibleDirs)
startIdx := 0
if len(f.dirs) > maxVisibleDirs {
halfVisible := maxVisibleDirs / 2
if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
startIdx = f.cursor - halfVisible
} else if f.cursor >= len(f.dirs)-halfVisible {
startIdx = len(f.dirs) - maxVisibleDirs
}
}
endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
for i := startIdx; i < endIdx; i++ {
file := f.dirs[i]
itemStyle := styles.BaseStyle().Width(adjustedWidth)
if i == f.cursor {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
filename := file.Name()
if len(filename) > adjustedWidth-4 {
filename = filename[:adjustedWidth-7] + "..."
}
if file.IsDir() {
filename = filename + "/"
}
files = append(files, itemStyle.Padding(0, 1).Render(filename))
}
// Pad to always show exactly 21 lines
for len(files) < maxVisibleDirs {
files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
}
currentPath := styles.BaseStyle().
Height(1).
Width(adjustedWidth).
Render(f.cwd.View())
viewportstyle := lipgloss.NewStyle().
Width(f.viewport.Width).
Background(t.Background()).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
BorderBackground(t.Background()).
Padding(2).
Render(f.viewport.View())
var insertExitText string
if f.IsCWDFocused() {
insertExitText = "Press esc to exit typing path"
} else {
insertExitText = "Press i to start typing path"
}
content := lipgloss.JoinVertical(
lipgloss.Left,
currentPath,
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
)
f.cwd.SetValue(f.cwd.Value())
contentStyle := styles.BaseStyle().Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4)
return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
}
type FilepickerCmp interface {
tea.Model
ToggleFilepicker(showFilepicker bool)
IsCWDFocused() bool
}
func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
f.ShowFilePicker = showFilepicker
}
func (f *filepickerCmp) IsCWDFocused() bool {
return f.cwd.Focused()
}
func NewFilepickerCmp(app *app.App) FilepickerCmp {
homepath, err := os.UserHomeDir()
if err != nil {
slog.Error("error loading user files")
return nil
}
baseDir := DirNode{parent: nil, directory: homepath}
dirs := readDir(homepath, false)
viewport := viewport.New(0, 0)
currentDirectory := textinput.New()
currentDirectory.CharLimit = 200
currentDirectory.Width = 44
currentDirectory.Cursor.Blink = true
currentDirectory.SetValue(baseDir.directory)
return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
}
func (f *filepickerCmp) getCurrentFileBelowCursor() {
if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
f.viewport.SetContent("Preview unavailable")
return
}
dir := f.dirs[f.cursor]
filename := dir.Name()
if !dir.IsDir() && isExtSupported(filename) {
fullPath := f.cwdDetails.directory + "/" + dir.Name()
go func() {
imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
if err != nil {
slog.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
return
}
f.viewport.SetContent(imageString)
}()
} else {
f.viewport.SetContent("Preview unavailable")
}
}
func readDir(path string, showHidden bool) []os.DirEntry {
slog.Info(fmt.Sprintf("Reading directory: %s", path))
entriesChan := make(chan []os.DirEntry, 1)
errChan := make(chan error, 1)
go func() {
dirEntries, err := os.ReadDir(path)
if err != nil {
status.Error(err.Error())
errChan <- err
return
}
entriesChan <- dirEntries
}()
select {
case dirEntries := <-entriesChan:
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return dirEntries
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if !isHidden {
if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
}
}
return sanitizedDirEntries
case <-errChan:
status.Error(fmt.Sprintf("Error reading directory %s", path))
return []os.DirEntry{}
case <-time.After(5 * time.Second):
status.Error(fmt.Sprintf("Timeout reading directory %s", path))
return []os.DirEntry{}
}
}
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
func isExtSupported(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
}

View File

@@ -0,0 +1,200 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type helpCmp struct {
width int
height int
keys []key.Binding
}
func (h *helpCmp) Init() tea.Cmd {
return nil
}
func (h *helpCmp) SetBindings(k []key.Binding) {
h.keys = k
}
func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = 90
h.height = msg.Height
}
return h, nil
}
func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
seen := make(map[string]struct{})
result := make([]key.Binding, 0, len(bindings))
// Process bindings in reverse order
for i := len(bindings) - 1; i >= 0; i-- {
b := bindings[i]
k := strings.Join(b.Keys(), " ")
if _, ok := seen[k]; ok {
// duplicate, skip
continue
}
seen[k] = struct{}{}
// Add to the beginning of result to maintain original order
result = append([]key.Binding{b}, result...)
}
return result
}
func (h *helpCmp) render() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
helpKeyStyle := styles.Bold().
Background(t.Background()).
Foreground(t.Text()).
Padding(0, 1, 0, 0)
helpDescStyle := styles.Regular().
Background(t.Background()).
Foreground(t.TextMuted())
// Compile list of bindings to render
bindings := removeDuplicateBindings(h.keys)
// Enumerate through each group of bindings, populating a series of
// pairs of columns, one for keys, one for descriptions
var (
pairs []string
width int
rows = 12 - 2
)
for i := 0; i < len(bindings); i += rows {
var (
keys []string
descs []string
)
for j := i; j < min(i+rows, len(bindings)); j++ {
keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
}
// Render pair of columns; beyond the first pair, render a three space
// left margin, in order to visually separate the pairs.
var cols []string
if len(pairs) > 0 {
cols = []string{baseStyle.Render(" ")}
}
maxDescWidth := 0
for _, desc := range descs {
if maxDescWidth < lipgloss.Width(desc) {
maxDescWidth = lipgloss.Width(desc)
}
}
for i := range descs {
remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
if remainingWidth > 0 {
descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
maxKeyWidth := 0
for _, key := range keys {
if maxKeyWidth < lipgloss.Width(key) {
maxKeyWidth = lipgloss.Width(key)
}
}
for i := range keys {
remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
if remainingWidth > 0 {
keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
cols = append(cols,
strings.Join(keys, "\n"),
strings.Join(descs, "\n"),
)
pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
// check whether it exceeds the maximum width avail (the width of the
// terminal, subtracting 2 for the borders).
width += lipgloss.Width(pair)
if width > h.width-2 {
break
}
pairs = append(pairs, pair)
}
// https://github.com/charmbracelet/lipgloss/issues/209
if len(pairs) > 1 {
prefix := pairs[:len(pairs)-1]
lastPair := pairs[len(pairs)-1]
prefix = append(prefix, lipgloss.Place(
lipgloss.Width(lastPair), // width
lipgloss.Height(prefix[0]), // height
lipgloss.Left, // x
lipgloss.Top, // y
lastPair, // content
lipgloss.WithWhitespaceBackground(t.Background()),
))
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
prefix...,
),
)
return content
}
// Join pairs of columns and enclose in a border
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
pairs...,
),
)
return content
}
func (h *helpCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := h.render()
header := baseStyle.
Bold(true).
Width(lipgloss.Width(content)).
Foreground(t.Primary()).
Render("Keyboard Shortcuts")
return baseStyle.Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
Width(h.width).
BorderBackground(t.Background()).
Render(
lipgloss.JoinVertical(lipgloss.Center,
header,
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
content,
),
)
}
type HelpCmp interface {
tea.Model
SetBindings([]key.Binding)
}
func NewHelpCmp() HelpCmp {
return &helpCmp{}
}

View File

@@ -0,0 +1,189 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// InitDialogCmp is a component that asks the user if they want to initialize the project.
type InitDialogCmp struct {
width, height int
selected int
keys initDialogKeyMap
}
// NewInitDialogCmp creates a new InitDialogCmp.
func NewInitDialogCmp() InitDialogCmp {
return InitDialogCmp{
selected: 0,
keys: initDialogKeyMap{},
}
}
type initDialogKeyMap struct {
Tab key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
Y key.Binding
N key.Binding
}
// ShortHelp implements key.Map.
func (k initDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("tab", "left", "right"),
key.WithHelp("tab/←/→", "toggle selection"),
),
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc", "q"),
key.WithHelp("esc/q", "cancel"),
),
key.NewBinding(
key.WithKeys("y", "n"),
key.WithHelp("y/n", "yes/no"),
),
}
}
// FullHelp implements key.Map.
func (k initDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// Init implements tea.Model.
func (m InitDialogCmp) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
m.selected = (m.selected + 1) % 2
return m, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// View implements tea.Model.
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Initialize Project")
explanation := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Render("Initialization generates a new AGENTS.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
question := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(1, 1).
Render("Would you like to initialize this project?")
maxWidth = min(maxWidth, m.width-10)
yesStyle := baseStyle
noStyle := baseStyle
if m.selected == 0 {
yesStyle = yesStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
noStyle = noStyle.
Background(t.Background()).
Foreground(t.Primary())
} else {
noStyle = noStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
yesStyle = yesStyle.
Background(t.Background()).
Foreground(t.Primary())
}
yes := yesStyle.Padding(0, 3).Render("Yes")
no := noStyle.Padding(0, 3).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
buttons = baseStyle.
Width(maxWidth).
Padding(1, 0).
Render(buttons)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
explanation,
question,
buttons,
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *InitDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m InitDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
type CloseInitDialogMsg struct {
Initialize bool
}
// ShowInitDialogMsg is a message that is sent to show the init dialog.
type ShowInitDialogMsg struct {
Show bool
}

View File

@@ -0,0 +1,327 @@
package dialog
import (
"context"
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
const (
numVisibleModels = 10
maxDialogWidth = 40
)
// CloseModelDialogMsg is sent when a model is selected
type CloseModelDialogMsg struct {
Provider *client.ProviderInfo
Model *client.ProviderModel
}
// ModelDialog interface for the model selection dialog
type ModelDialog interface {
tea.Model
layout.Bindings
SetProviders(providers []client.ProviderInfo)
}
type modelDialogCmp struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
model *client.ProviderModel
selectedIdx int
width int
height int
scrollOffset int
hScrollOffset int
hScrollPossible bool
}
type modelKeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
H key.Binding
L key.Binding
}
var modelKeys = modelKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous model"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next model"),
),
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next model"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous model"),
),
H: key.NewBinding(
key.WithKeys("h"),
key.WithHelp("h", "scroll left"),
),
L: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "scroll right"),
),
}
func (m *modelDialogCmp) Init() tea.Cmd {
// cfg := config.Get()
// modelInfo := GetSelectedModel(cfg)
// m.availableProviders = getEnabledProviders(cfg)
// m.hScrollPossible = len(m.availableProviders) > 1
// m.provider = modelInfo.Provider
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
// m.setupModelsForProvider(m.provider)
m.availableProviders, _ = m.app.ListProviders(context.Background())
m.hScrollOffset = 0
m.hScrollPossible = len(m.availableProviders) > 1
m.provider = m.availableProviders[m.hScrollOffset]
return nil
}
func (m *modelDialogCmp) SetProviders(providers []client.ProviderInfo) {
m.availableProviders = providers
}
func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
m.moveSelectionUp()
case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
m.moveSelectionDown()
case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
if m.hScrollPossible {
m.switchProvider(-1)
}
case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
if m.hScrollPossible {
m.switchProvider(1)
}
case key.Matches(msg, modelKeys.Enter):
return m, util.CmdHandler(CloseModelDialogMsg{Provider: &m.provider, Model: &m.provider.Models[m.selectedIdx]})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(CloseModelDialogMsg{})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// moveSelectionUp moves the selection up or wraps to bottom
func (m *modelDialogCmp) moveSelectionUp() {
if m.selectedIdx > 0 {
m.selectedIdx--
} else {
m.selectedIdx = len(m.provider.Models) - 1
m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
}
// Keep selection visible
if m.selectedIdx < m.scrollOffset {
m.scrollOffset = m.selectedIdx
}
}
// moveSelectionDown moves the selection down or wraps to top
func (m *modelDialogCmp) moveSelectionDown() {
if m.selectedIdx < len(m.provider.Models)-1 {
m.selectedIdx++
} else {
m.selectedIdx = 0
m.scrollOffset = 0
}
// Keep selection visible
if m.selectedIdx >= m.scrollOffset+numVisibleModels {
m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
}
}
func (m *modelDialogCmp) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
// Ensure we stay within bounds
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
if newOffset >= len(m.availableProviders) {
newOffset = 0
}
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.setupModelsForProvider(m.provider.Id)
}
func (m *modelDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Capitalize first letter of provider name
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.Background(t.Primary()).
Foreground(t.Background()).Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(*m.provider.Models[i].Name))
}
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
var indicator string
if len(m.provider.Models) > numVisibleModels {
if m.scrollOffset > 0 {
indicator += "↑ "
}
if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
indicator += "↓ "
}
}
if m.hScrollPossible {
if m.hScrollOffset > 0 {
indicator = "← " + indicator
}
if m.hScrollOffset < len(m.availableProviders)-1 {
indicator += "→"
}
}
if indicator == "" {
return ""
}
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
return baseStyle.
Foreground(t.Primary()).
Width(maxWidth).
Align(lipgloss.Right).
Bold(true).
Render(indicator)
}
func (m *modelDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(modelKeys)
}
// findProviderIndex returns the index of the provider in the list, or -1 if not found
// func findProviderIndex(providers []string, provider string) int {
// for i, p := range providers {
// if p == provider {
// return i
// }
// }
// return -1
// }
func (m *modelDialogCmp) setupModelsForProvider(_ string) {
m.selectedIdx = 0
m.scrollOffset = 0
// cfg := config.Get()
// agentCfg := cfg.Agents[config.AgentPrimary]
// selectedModelId := agentCfg.Model
// m.provider = provider
// m.models = getModelsForProvider(provider)
// Try to select the current model if it belongs to this provider
// if provider == models.SupportedModels[selectedModelId].Provider {
// for i, model := range m.models {
// if model.ID == selectedModelId {
// m.selectedIdx = i
// // Adjust scroll position to keep selected model visible
// if m.selectedIdx >= numVisibleModels {
// m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
// }
// break
// }
// }
// }
}
func NewModelDialogCmp(app *app.App) ModelDialog {
return &modelDialogCmp{
app: app,
}
}

View File

@@ -0,0 +1,502 @@
package dialog
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"strings"
)
type PermissionAction string
// Permission responses
const (
PermissionAllow PermissionAction = "allow"
PermissionAllowForSession PermissionAction = "allow_session"
PermissionDeny PermissionAction = "deny"
)
// PermissionResponseMsg represents the user's response to a permission request
type PermissionResponseMsg struct {
// Permission permission.PermissionRequest
Action PermissionAction
}
// PermissionDialogCmp interface for permission dialog component
type PermissionDialogCmp interface {
tea.Model
layout.Bindings
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
type permissionsMapping struct {
Left key.Binding
Right key.Binding
EnterSpace key.Binding
Allow key.Binding
AllowSession key.Binding
Deny key.Binding
Tab key.Binding
}
var permissionsKeys = permissionsMapping{
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "switch options"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Allow: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "allow"),
),
AllowSession: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "allow for session"),
),
Deny: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "deny"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
width int
height int
// permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
contentViewPort viewport.Model
selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
diffCache map[string]string
markdownCache map[string]string
}
func (p *permissionDialogCmp) Init() tea.Cmd {
return p.contentViewPort.Init()
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.windowSize = msg
cmd := p.SetSize()
cmds = append(cmds, cmd)
p.markdownCache = make(map[string]string)
p.diffCache = make(map[string]string)
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
// p.selectedOption = (p.selectedOption + 1) % 3
// return p, nil
// case key.Matches(msg, permissionsKeys.Left):
// p.selectedOption = (p.selectedOption + 2) % 3
// case key.Matches(msg, permissionsKeys.EnterSpace):
// return p, p.selectCurrentOption()
// case key.Matches(msg, permissionsKeys.Allow):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
// case key.Matches(msg, permissionsKeys.AllowSession):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
// case key.Matches(msg, permissionsKeys.Deny):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
// default:
// // Pass other keys to viewport
// viewPort, cmd := p.contentViewPort.Update(msg)
// p.contentViewPort = viewPort
// cmds = append(cmds, cmd)
// }
}
return p, tea.Batch(cmds...)
}
func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
var action PermissionAction
switch p.selectedOption {
case 0:
action = PermissionAllow
case 1:
action = PermissionAllowForSession
case 2:
action = PermissionDeny
}
return util.CmdHandler(PermissionResponseMsg{Action: action}) // , Permission: p.permission})
}
func (p *permissionDialogCmp) renderButtons() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
allowStyle := baseStyle
allowSessionStyle := baseStyle
denyStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
// Style the selected button
switch p.selectedOption {
case 0:
allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 1:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 2:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
}
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
content := lipgloss.JoinHorizontal(
lipgloss.Left,
allowButton,
spacerStyle.Render(" "),
allowSessionButton,
spacerStyle.Render(" "),
denyButton,
spacerStyle.Render(" "),
)
remainingWidth := p.width - lipgloss.Width(content)
if remainingWidth > 0 {
content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
}
return content
}
func (p *permissionDialogCmp) renderHeader() string {
return "NOT IMPLEMENTED"
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
// toolValue := baseStyle.
// Foreground(t.Text()).
// Width(p.width - lipgloss.Width(toolKey)).
// Render(fmt.Sprintf(": %s", p.permission.ToolName))
//
// pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
//
// // Get the current working directory to display relative path
// relativePath := p.permission.Path
// if filepath.IsAbs(relativePath) {
// if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
// relativePath = cwd
// }
// }
//
// pathValue := baseStyle.
// Foreground(t.Text()).
// Width(p.width - lipgloss.Width(pathKey)).
// Render(fmt.Sprintf(": %s", relativePath))
//
// headerParts := []string{
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// toolKey,
// toolValue,
// ),
// baseStyle.Render(strings.Repeat(" ", p.width)),
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// pathKey,
// pathValue,
// ),
// baseStyle.Render(strings.Repeat(" ", p.width)),
// }
//
// // Add tool-specific header information
// switch p.permission.ToolName {
// case "bash":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
// case "edit":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
// case "write":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
// case "fetch":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
// }
//
// return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
}
func (p *permissionDialogCmp) renderBashContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
// content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderEditContent() string {
// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderPatchContent() string {
// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderWriteContent() string {
// if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
// // Use the cache for diff rendering
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderFetchContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
// content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderDefaultContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// content := p.permission.Description
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
//
// if renderedContent == "" {
// return ""
// }
//
return p.styleViewport()
}
func (p *permissionDialogCmp) styleViewport() string {
t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle().
Background(t.Background())
return contentStyle.Render(p.contentViewPort.View())
}
func (p *permissionDialogCmp) render() string {
return "NOT IMPLEMENTED"
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// title := baseStyle.
// Bold(true).
// Width(p.width - 4).
// Foreground(t.Primary()).
// Render("Permission Required")
// // Render header
// headerContent := p.renderHeader()
// // Render buttons
// buttons := p.renderButtons()
//
// // Calculate content height dynamically based on window size
// p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
// p.contentViewPort.Width = p.width - 4
//
// // Render content based on tool type
// var contentFinal string
// switch p.permission.ToolName {
// case "bash":
// contentFinal = p.renderBashContent()
// case "edit":
// contentFinal = p.renderEditContent()
// case "patch":
// contentFinal = p.renderPatchContent()
// case "write":
// contentFinal = p.renderWriteContent()
// case "fetch":
// contentFinal = p.renderFetchContent()
// default:
// contentFinal = p.renderDefaultContent()
// }
//
// content := lipgloss.JoinVertical(
// lipgloss.Top,
// title,
// baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
// headerContent,
// contentFinal,
// buttons,
// baseStyle.Render(strings.Repeat(" ", p.width-4)),
// )
//
// return baseStyle.
// Padding(1, 0, 0, 1).
// Border(lipgloss.RoundedBorder()).
// BorderBackground(t.Background()).
// BorderForeground(t.TextMuted()).
// Width(p.width).
// Height(p.height).
// Render(
// content,
// )
}
func (p *permissionDialogCmp) View() string {
return p.render()
}
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(permissionsKeys)
}
func (p *permissionDialogCmp) SetSize() tea.Cmd {
// if p.permission.ID == "" {
// return nil
// }
// switch p.permission.ToolName {
// case "bash":
// p.width = int(float64(p.windowSize.Width) * 0.4)
// p.height = int(float64(p.windowSize.Height) * 0.3)
// case "edit":
// p.width = int(float64(p.windowSize.Width) * 0.8)
// p.height = int(float64(p.windowSize.Height) * 0.8)
// case "write":
// p.width = int(float64(p.windowSize.Width) * 0.8)
// p.height = int(float64(p.windowSize.Height) * 0.8)
// case "fetch":
// p.width = int(float64(p.windowSize.Width) * 0.4)
// p.height = int(float64(p.windowSize.Height) * 0.3)
// default:
// p.width = int(float64(p.windowSize.Width) * 0.7)
// p.height = int(float64(p.windowSize.Height) * 0.5)
// }
return nil
}
// func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
// p.permission = permission
// return p.SetSize()
// }
// Helper to get or set cached diff content
func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
if cached, ok := c.diffCache[key]; ok {
return cached
}
content, err := generator()
if err != nil {
return fmt.Sprintf("Error formatting diff: %v", err)
}
c.diffCache[key] = content
return content
}
// Helper to get or set cached markdown content
func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
if cached, ok := c.markdownCache[key]; ok {
return cached
}
content, err := generator()
if err != nil {
return fmt.Sprintf("Error rendering markdown: %v", err)
}
c.markdownCache[key] = content
return content
}
func NewPermissionDialogCmp() PermissionDialogCmp {
// Create viewport for content
contentViewport := viewport.New(0, 0)
return &permissionDialogCmp{
contentViewPort: contentViewport,
selectedOption: 0, // Default to "Allow"
diffCache: make(map[string]string),
markdownCache: make(map[string]string),
}
}

View File

@@ -0,0 +1,136 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const question = "Are you sure you want to quit?"
type CloseQuitMsg struct{}
type QuitDialog interface {
tea.Model
layout.Bindings
}
type quitDialogCmp struct {
selectedNo bool
}
type helpMapping struct {
LeftRight key.Binding
EnterSpace key.Binding
Yes key.Binding
No key.Binding
Tab key.Binding
}
var helpKeys = helpMapping{
LeftRight: key.NewBinding(
key.WithKeys("left", "right"),
key.WithHelp("←/→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Yes: key.NewBinding(
key.WithKeys("y", "Y"),
key.WithHelp("y/Y", "yes"),
),
No: key.NewBinding(
key.WithKeys("n", "N"),
key.WithHelp("n/N", "no"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
}
func (q *quitDialogCmp) Init() tea.Cmd {
return nil
}
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, helpKeys.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
return q, util.CmdHandler(CloseQuitMsg{})
case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
case key.Matches(msg, helpKeys.No):
return q, util.CmdHandler(CloseQuitMsg{})
}
}
return q, nil
}
func (q *quitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
if q.selectedNo {
noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
noButton := noStyle.Padding(0, 1).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
width := lipgloss.Width(question)
remainingWidth := width - lipgloss.Width(buttons)
if remainingWidth > 0 {
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
}
content := baseStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
question,
"",
buttons,
),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(helpKeys)
}
func NewQuitCmp() QuitDialog {
return &quitDialogCmp{
selectedNo: true,
}
}

View File

@@ -0,0 +1,230 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// CloseSessionDialogMsg is sent when the session dialog is closed
type CloseSessionDialogMsg struct {
Session *client.SessionInfo
}
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
tea.Model
layout.Bindings
SetSessions(sessions []client.SessionInfo)
SetSelectedSession(sessionID string)
}
type sessionDialogCmp struct {
sessions []client.SessionInfo
selectedIdx int
width int
height int
selectedSessionID string
}
type sessionKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var sessionKeys = sessionKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous session"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next session"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select session"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next session"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous session"),
),
}
func (s *sessionDialogCmp) Init() tea.Cmd {
return nil
}
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
if s.selectedIdx > 0 {
s.selectedIdx--
}
return s, nil
case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
if s.selectedIdx < len(s.sessions)-1 {
s.selectedIdx++
}
return s, nil
case key.Matches(msg, sessionKeys.Enter):
if len(s.sessions) > 0 {
selectedSession := s.sessions[s.selectedIdx]
s.selectedSessionID = selectedSession.Id
return s, util.CmdHandler(CloseSessionDialogMsg{
Session: &selectedSession,
})
}
case key.Matches(msg, sessionKeys.Escape):
return s, util.CmdHandler(CloseSessionDialogMsg{})
}
}
return s, nil
}
func (s *sessionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(s.sessions) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No sessions available")
}
// Calculate max width needed for session titles
maxWidth := 40 // Minimum width
for _, sess := range s.sessions {
if len(sess.Title) > maxWidth-4 { // Account for padding
maxWidth = len(sess.Title) + 4
}
}
maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
// Limit height to avoid taking up too much screen space
maxVisibleSessions := min(10, len(s.sessions))
// Build the session list
sessionItems := make([]string, 0, maxVisibleSessions)
startIdx := 0
// If we have more sessions than can be displayed, adjust the start index
if len(s.sessions) > maxVisibleSessions {
// Center the selected item when possible
halfVisible := maxVisibleSessions / 2
if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
startIdx = s.selectedIdx - halfVisible
} else if s.selectedIdx >= len(s.sessions)-halfVisible {
startIdx = len(s.sessions) - maxVisibleSessions
}
}
endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
for i := startIdx; i < endIdx; i++ {
sess := s.sessions[i]
itemStyle := baseStyle.Width(maxWidth)
if i == s.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
}
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Switch Session")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (s *sessionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(sessionKeys)
}
func (s *sessionDialogCmp) SetSessions(sessions []client.SessionInfo) {
s.sessions = sessions
// If we have a selected session ID, find its index
if s.selectedSessionID != "" {
for i, sess := range sessions {
if sess.Id == s.selectedSessionID {
s.selectedIdx = i
return
}
}
}
// Default to first session if selected not found
s.selectedIdx = 0
}
func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
s.selectedSessionID = sessionID
// Update the selected index if sessions are already loaded
if len(s.sessions) > 0 {
for i, sess := range s.sessions {
if sess.Id == sessionID {
s.selectedIdx = i
return
}
}
}
}
// NewSessionDialogCmp creates a new session switching dialog
func NewSessionDialogCmp() SessionDialog {
return &sessionDialogCmp{
sessions: []client.SessionInfo{},
selectedIdx: 0,
selectedSessionID: "",
}
}

View File

@@ -0,0 +1,199 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// ThemeChangedMsg is sent when the theme is changed
type ThemeChangedMsg struct {
ThemeName string
}
// CloseThemeDialogMsg is sent when the theme dialog is closed
type CloseThemeDialogMsg struct{}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
tea.Model
layout.Bindings
}
type themeDialogCmp struct {
themes []string
selectedIdx int
width int
height int
currentTheme string
}
type themeKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var themeKeys = themeKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous theme"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next theme"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select theme"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next theme"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous theme"),
),
}
func (t *themeDialogCmp) Init() tea.Cmd {
// Load available themes and update selectedIdx based on current theme
t.themes = theme.AvailableThemes()
t.currentTheme = theme.CurrentThemeName()
// Find the current theme in the list
for i, name := range t.themes {
if name == t.currentTheme {
t.selectedIdx = i
break
}
}
return nil
}
func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
if t.selectedIdx > 0 {
t.selectedIdx--
}
return t, nil
case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
if t.selectedIdx < len(t.themes)-1 {
t.selectedIdx++
}
return t, nil
case key.Matches(msg, themeKeys.Enter):
if len(t.themes) > 0 {
previousTheme := theme.CurrentThemeName()
selectedTheme := t.themes[t.selectedIdx]
if previousTheme == selectedTheme {
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
if err := theme.SetTheme(selectedTheme); err != nil {
status.Error(err.Error())
return t, nil
}
return t, util.CmdHandler(ThemeChangedMsg{
ThemeName: selectedTheme,
})
}
case key.Matches(msg, themeKeys.Escape):
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
}
return t, nil
}
func (t *themeDialogCmp) View() string {
currentTheme := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(t.themes) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(40).
Render("No themes available")
}
// Calculate max width needed for theme names
maxWidth := 40 // Minimum width
for _, themeName := range t.themes {
if len(themeName) > maxWidth-4 { // Account for padding
maxWidth = len(themeName) + 4
}
}
maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
// Build the theme list
themeItems := make([]string, 0, len(t.themes))
for i, themeName := range t.themes {
itemStyle := baseStyle.Width(maxWidth)
if i == t.selectedIdx {
itemStyle = itemStyle.
Background(currentTheme.Primary()).
Foreground(currentTheme.Background()).
Bold(true)
}
themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
}
title := baseStyle.
Foreground(currentTheme.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Select Theme")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (t *themeDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(themeKeys)
}
// NewThemeDialogCmp creates a new theme switching dialog
func NewThemeDialogCmp() ThemeDialog {
return &themeDialogCmp{
themes: []string{},
selectedIdx: 0,
currentTheme: "",
}
}

View File

@@ -0,0 +1,178 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
const (
maxToolsDialogWidth = 60
maxVisibleTools = 15
)
// ToolsDialog interface for the tools list dialog
type ToolsDialog interface {
tea.Model
layout.Bindings
SetTools(tools []string)
}
// ShowToolsDialogMsg is sent to show the tools dialog
type ShowToolsDialogMsg struct {
Show bool
}
// CloseToolsDialogMsg is sent when the tools dialog is closed
type CloseToolsDialogMsg struct{}
type toolItem struct {
name string
}
func (t toolItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width).
Background(th.Background())
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.Background()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Render(t.name)
}
type toolsDialogCmp struct {
tools []toolItem
width int
height int
list utilComponents.SimpleList[toolItem]
}
type toolsKeyMap struct {
Up key.Binding
Down key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var toolsKeys = toolsKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous tool"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next tool"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next tool"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous tool"),
),
}
func (m *toolsDialogCmp) Init() tea.Cmd {
return nil
}
func (m *toolsDialogCmp) SetTools(tools []string) {
var toolItems []toolItem
for _, name := range tools {
toolItems = append(toolItems, toolItem{name: name})
}
m.tools = toolItems
m.list.SetItems(toolItems)
}
func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, toolsKeys.Escape):
return m, func() tea.Msg { return CloseToolsDialogMsg{} }
// Pass other key messages to the list component
default:
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// For non-key messages
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
func (m *toolsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxToolsDialogWidth).
Padding(0, 0, 1).
Render("Available Tools")
// Calculate dialog width based on content
dialogWidth := min(maxToolsDialogWidth, m.width/2)
m.list.SetMaxWidth(dialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
m.list.View(),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (m *toolsDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(toolsKeys)
}
func NewToolsDialogCmp() ToolsDialog {
list := utilComponents.NewSimpleList[toolItem](
[]toolItem{},
maxVisibleTools,
"No tools available",
true,
)
return &toolsDialogCmp{
list: list,
}
}

View File

@@ -0,0 +1,818 @@
package diff
import (
"bytes"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/sst/opencode/internal/theme"
)
// -------------------------------------------------------------------------
// Core Types
// -------------------------------------------------------------------------
// LineType represents the kind of line in a diff.
type LineType int
const (
LineContext LineType = iota // Line exists in both files
LineAdded // Line added in the new file
LineRemoved // Line removed from the old file
)
// Segment represents a portion of a line for intra-line highlighting
type Segment struct {
Start int
End int
Type LineType
Text string
}
// DiffLine represents a single line in a diff
type DiffLine struct {
OldLineNo int // Line number in old file (0 for added lines)
NewLineNo int // Line number in new file (0 for removed lines)
Kind LineType // Type of line (added, removed, context)
Content string // Content of the line
Segments []Segment // Segments for intraline highlighting
}
// Hunk represents a section of changes in a diff
type Hunk struct {
Header string
Lines []DiffLine
}
// DiffResult contains the parsed result of a diff
type DiffResult struct {
OldFile string
NewFile string
Hunks []Hunk
}
// linePair represents a pair of lines for side-by-side display
type linePair struct {
left *DiffLine
right *DiffLine
}
// -------------------------------------------------------------------------
// Side-by-Side Configuration
// -------------------------------------------------------------------------
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
}
// SideBySideOption modifies a SideBySideConfig
type SideBySideOption func(*SideBySideConfig)
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithTotalWidth sets the total width for side-by-side view
func WithTotalWidth(width int) SideBySideOption {
return func(s *SideBySideConfig) {
if width > 0 {
s.TotalWidth = width
}
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
// ParseUnifiedDiff parses a unified diff format string into structured data
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
lines := strings.Split(diff, "\n")
var oldLine, newLine int
inFileHeader := true
for _, line := range lines {
// Parse file headers
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/")
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/")
inFileHeader = false
continue
}
}
// Parse hunk headers
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: []DiffLine{},
}
oldStart, _ := strconv.Atoi(matches[1])
newStart, _ := strconv.Atoi(matches[3])
oldLine = oldStart
newLine = newStart
continue
}
// Ignore "No newline at end of file" markers
if strings.HasPrefix(line, "\\ No newline at end of file") {
continue
}
if currentHunk == nil {
continue
}
// Process the line based on its prefix
if len(line) > 0 {
switch line[0] {
case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: 0,
NewLineNo: newLine,
Kind: LineAdded,
Content: line[1:],
})
newLine++
case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: 0,
Kind: LineRemoved,
Content: line[1:],
})
oldLine++
default:
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: line,
})
oldLine++
newLine++
}
} else {
// Handle empty lines
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
oldLine++
newLine++
}
}
// Add the last hunk if there is one
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, nil
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
func HighlightIntralineChanges(h *Hunk) {
var updated []DiffLine
dmp := diffmatchpatch.New()
for i := 0; i < len(h.Lines); i++ {
// Look for removed line followed by added line
if i+1 < len(h.Lines) &&
h.Lines[i].Kind == LineRemoved &&
h.Lines[i+1].Kind == LineAdded {
oldLine := h.Lines[i]
newLine := h.Lines[i+1]
// Find character-level differences
patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
patches = dmp.DiffCleanupSemantic(patches)
patches = dmp.DiffCleanupMerge(patches)
patches = dmp.DiffCleanupEfficiency(patches)
segments := make([]Segment, 0)
removeStart := 0
addStart := 0
for _, patch := range patches {
switch patch.Type {
case diffmatchpatch.DiffDelete:
segments = append(segments, Segment{
Start: removeStart,
End: removeStart + len(patch.Text),
Type: LineRemoved,
Text: patch.Text,
})
removeStart += len(patch.Text)
case diffmatchpatch.DiffInsert:
segments = append(segments, Segment{
Start: addStart,
End: addStart + len(patch.Text),
Type: LineAdded,
Text: patch.Text,
})
addStart += len(patch.Text)
default:
// Context text, no highlighting needed
removeStart += len(patch.Text)
addStart += len(patch.Text)
}
}
oldLine.Segments = segments
newLine.Segments = segments
updated = append(updated, oldLine, newLine)
i++ // Skip the next line as we've already processed it
} else {
updated = append(updated, h.Lines[i])
}
}
h.Lines = updated
}
// pairLines converts a flat list of diff lines to pairs for side-by-side display
func pairLines(lines []DiffLine) []linePair {
var pairs []linePair
i := 0
for i < len(lines) {
switch lines[i].Kind {
case LineRemoved:
// Check if the next line is an addition, if so pair them
if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
i += 2
} else {
pairs = append(pairs, linePair{left: &lines[i], right: nil})
i++
}
case LineAdded:
pairs = append(pairs, linePair{left: nil, right: &lines[i]})
i++
case LineContext:
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
i++
}
}
return pairs
}
// -------------------------------------------------------------------------
// Syntax Highlighting
// -------------------------------------------------------------------------
// SyntaxHighlight applies syntax highlighting to text based on file extension
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
l = lexers.Analyse(source)
}
if l == nil {
l = lexers.Fallback
}
l = chroma.Coalesce(l)
// Get the formatter
f := formatters.Get(formatter)
if f == nil {
f = formatters.Fallback
}
// Dynamic theme based on current theme values
syntaxThemeXml := fmt.Sprintf(`
<style name="opencode-theme">
<!-- Base colors -->
<entry type="Background" style="bg:%s"/>
<entry type="Text" style="%s"/>
<entry type="Other" style="%s"/>
<entry type="Error" style="%s"/>
<!-- Keywords -->
<entry type="Keyword" style="%s"/>
<entry type="KeywordConstant" style="%s"/>
<entry type="KeywordDeclaration" style="%s"/>
<entry type="KeywordNamespace" style="%s"/>
<entry type="KeywordPseudo" style="%s"/>
<entry type="KeywordReserved" style="%s"/>
<entry type="KeywordType" style="%s"/>
<!-- Names -->
<entry type="Name" style="%s"/>
<entry type="NameAttribute" style="%s"/>
<entry type="NameBuiltin" style="%s"/>
<entry type="NameBuiltinPseudo" style="%s"/>
<entry type="NameClass" style="%s"/>
<entry type="NameConstant" style="%s"/>
<entry type="NameDecorator" style="%s"/>
<entry type="NameEntity" style="%s"/>
<entry type="NameException" style="%s"/>
<entry type="NameFunction" style="%s"/>
<entry type="NameLabel" style="%s"/>
<entry type="NameNamespace" style="%s"/>
<entry type="NameOther" style="%s"/>
<entry type="NameTag" style="%s"/>
<entry type="NameVariable" style="%s"/>
<entry type="NameVariableClass" style="%s"/>
<entry type="NameVariableGlobal" style="%s"/>
<entry type="NameVariableInstance" style="%s"/>
<!-- Literals -->
<entry type="Literal" style="%s"/>
<entry type="LiteralDate" style="%s"/>
<entry type="LiteralString" style="%s"/>
<entry type="LiteralStringBacktick" style="%s"/>
<entry type="LiteralStringChar" style="%s"/>
<entry type="LiteralStringDoc" style="%s"/>
<entry type="LiteralStringDouble" style="%s"/>
<entry type="LiteralStringEscape" style="%s"/>
<entry type="LiteralStringHeredoc" style="%s"/>
<entry type="LiteralStringInterpol" style="%s"/>
<entry type="LiteralStringOther" style="%s"/>
<entry type="LiteralStringRegex" style="%s"/>
<entry type="LiteralStringSingle" style="%s"/>
<entry type="LiteralStringSymbol" style="%s"/>
<!-- Numbers -->
<entry type="LiteralNumber" style="%s"/>
<entry type="LiteralNumberBin" style="%s"/>
<entry type="LiteralNumberFloat" style="%s"/>
<entry type="LiteralNumberHex" style="%s"/>
<entry type="LiteralNumberInteger" style="%s"/>
<entry type="LiteralNumberIntegerLong" style="%s"/>
<entry type="LiteralNumberOct" style="%s"/>
<!-- Operators -->
<entry type="Operator" style="%s"/>
<entry type="OperatorWord" style="%s"/>
<entry type="Punctuation" style="%s"/>
<!-- Comments -->
<entry type="Comment" style="%s"/>
<entry type="CommentHashbang" style="%s"/>
<entry type="CommentMultiline" style="%s"/>
<entry type="CommentSingle" style="%s"/>
<entry type="CommentSpecial" style="%s"/>
<entry type="CommentPreproc" style="%s"/>
<!-- Generic styles -->
<entry type="Generic" style="%s"/>
<entry type="GenericDeleted" style="%s"/>
<entry type="GenericEmph" style="italic %s"/>
<entry type="GenericError" style="%s"/>
<entry type="GenericHeading" style="bold %s"/>
<entry type="GenericInserted" style="%s"/>
<entry type="GenericOutput" style="%s"/>
<entry type="GenericPrompt" style="%s"/>
<entry type="GenericStrong" style="bold %s"/>
<entry type="GenericSubheading" style="bold %s"/>
<entry type="GenericTraceback" style="%s"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getColor(t.Background()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
style := chroma.MustNewXMLStyle(r)
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t
},
).Build()
if err != nil {
s = styles.Fallback
}
// Tokenize and format
it, err := l.Tokenise(nil, source)
if err != nil {
return err
}
return f.Format(w, s, it)
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
if lipgloss.HasDarkBackground() {
return adaptiveColor.Dark
}
return adaptiveColor.Light
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
var buf bytes.Buffer
err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
if err != nil {
return line
}
return buf.String()
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
return
}
// -------------------------------------------------------------------------
// Rendering Functions
// -------------------------------------------------------------------------
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
// Find all ANSI sequences in the content
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
// Build a mapping of visible character positions to their actual indices
visibleIdx := 0
ansiSequences := make(map[int]string)
lastAnsiSeq := "\x1b[0m" // Default reset sequence
for i := 0; i < len(content); {
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
ansiSequences[visibleIdx] = content[match[0]:match[1]]
lastAnsiSeq = content[match[0]:match[1]]
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// For non-ANSI positions, store the last ANSI sequence
if _, exists := ansiSequences[visibleIdx]; !exists {
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
i++
}
// Apply highlighting
var sb strings.Builder
inSelection := false
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// Check for segment boundaries
for _, seg := range segments {
if seg.Type == segmentType {
if currentPos == seg.Start {
inSelection = true
}
if currentPos == seg.End {
inSelection = false
}
}
}
// Get current character
char := string(content[i])
if inSelection {
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString("\x1b[48;2;")
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
sb.WriteString("\x1b[0m")
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
} else {
// Not in selection, just copy the character
sb.WriteString(char)
}
currentPos++
i++
}
return sb.String()
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
dl *DiffLine,
colWidth int,
isLeftColumn bool,
t theme.Theme,
) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type and column
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightType LineType
var highlightColor lipgloss.AdaptiveColor
if isLeftColumn {
// Left column logic
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved()
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for left column
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
} else {
// Right column logic
switch dl.Kind {
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for right column
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
}
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
case LineAdded:
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
case LineContext:
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
}
// Add a padding space for added/removed lines
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
// Apply options to create the configuration
config := NewSideBySideConfig(opts...)
// Make a copy of the hunk so we don't modify the original
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
// Pair lines for side-by-side display
pairs := pairLines(hunkCopy.Lines)
// Calculate column width
colWidth := config.TotalWidth / 2
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
return sb.String()
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(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...)
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...))
}
return sb.String(), nil
}

View File

@@ -0,0 +1,58 @@
package qr
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/theme"
"rsc.io/qr"
)
var tops_bottoms = []rune{' ', '▀', '▄', '█'}
// Generate a text string to a QR code, which you can write to a terminal or file.
func Generate(text string) (string, int, error) {
code, err := qr.Encode(text, qr.Level(0))
if err != nil {
return "", 0, err
}
t := theme.CurrentTheme()
if t == nil {
return "", 0, err
}
// Create lipgloss style for QR code with theme colors
qrStyle := lipgloss.NewStyle().
Foreground(t.Text()).
Background(t.Background())
var result strings.Builder
// content
for y := 0; y < code.Size-1; y += 2 {
var line strings.Builder
for x := 0; x < code.Size; x += 1 {
var num int8
if code.Black(x, y) {
num += 1
}
if code.Black(x, y+1) {
num += 2
}
line.WriteRune(tops_bottoms[num])
}
result.WriteString(qrStyle.Render(line.String()) + "\n")
}
// add lower border when required (only required when QR size is odd)
if code.Size%2 == 1 {
var borderLine strings.Builder
for range code.Size {
borderLine.WriteRune('▀')
}
result.WriteString(qrStyle.Render(borderLine.String()) + "\n")
}
return result.String(), code.Size, nil
}

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
package utilComponents
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type SimpleListItem interface {
Render(selected bool, width int) string
}
type SimpleList[T SimpleListItem] interface {
tea.Model
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
}
type simpleListCmp[T SimpleListItem] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleItems int
useAlphaNumericKeys bool
width int
height int
}
type simpleListKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = simpleListKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous list item"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next list item"),
),
UpAlpha: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous list item"),
),
DownAlpha: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next list item"),
),
}
func (c *simpleListCmp[T]) Init() tea.Cmd {
return nil
}
func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
}
return c, nil
}
}
return c, nil
}
func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx
}
var zero T
return zero, -1
}
func (c *simpleListCmp[T]) SetItems(items []T) {
c.selectedIdx = 0
c.items = items
}
func (c *simpleListCmp[T]) GetItems() []T {
return c.items
}
func (c *simpleListCmp[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListCmp[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
}
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(items)-halfVisible {
startIdx = len(items) - maxVisibleItems
}
}
endIdx := min(startIdx+maxVisibleItems, len(items))
listItems := make([]string, 0, maxVisibleItems)
for i := startIdx; i < endIdx; i++ {
item := items[i]
title := item.Render(i == c.selectedIdx, maxWidth)
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
return &simpleListCmp[T]{
fallbackMsg: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
useAlphaNumericKeys: useAlphaNumericKeys,
selectedIdx: 0,
}
}