summaryrefslogtreecommitdiffhomepage
path: root/internal/tui
diff options
context:
space:
mode:
authorJay V <[email protected]>2025-05-21 15:01:25 -0400
committerJay V <[email protected]>2025-05-21 15:01:25 -0400
commit9049295cc961b250be6144585dde322e778534d7 (patch)
treec8a2f09ed6cea54eb9587243eb7dbe298fef1b20 /internal/tui
parent4526b14b17dc49f3ef4f3b1a1d02eff5c6b6b59f (diff)
parentdff8e77eb6d1709fa1ddeb52d0d9c19afd13d385 (diff)
downloadopencode-9049295cc961b250be6144585dde322e778534d7.tar.gz
opencode-9049295cc961b250be6144585dde322e778534d7.zip
Merge branch 'dev' into docs
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/components/chat/editor.go112
-rw-r--r--internal/tui/components/chat/messages.go (renamed from internal/tui/components/chat/list.go)2
-rw-r--r--internal/tui/components/chat/sidebar.go10
-rw-r--r--internal/tui/components/dialog/models.go2
-rw-r--r--internal/tui/components/dialog/permission.go13
-rw-r--r--internal/tui/components/dialog/tools.go178
-rw-r--r--internal/tui/components/logs/details.go6
-rw-r--r--internal/tui/components/spinner/spinner.go127
-rw-r--r--internal/tui/components/spinner/spinner_test.go24
-rw-r--r--internal/tui/image/clipboard_unix.go49
-rw-r--r--internal/tui/image/clipboard_windows.go192
-rw-r--r--internal/tui/image/images.go12
-rw-r--r--internal/tui/layout/container.go11
-rw-r--r--internal/tui/theme/manager.go48
-rw-r--r--internal/tui/theme/theme.go29
-rw-r--r--internal/tui/tui.go127
16 files changed, 909 insertions, 33 deletions
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index 0b2c9abb8..212ad5529 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -2,6 +2,7 @@ package chat
import (
"fmt"
+ "log/slog"
"os"
"os/exec"
"slices"
@@ -16,6 +17,7 @@ import (
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
+ "github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -23,17 +25,23 @@ import (
)
type editorCmp struct {
- width int
- height int
- app *app.App
- textarea textarea.Model
- attachments []message.Attachment
- deleteMode bool
+ width int
+ height int
+ app *app.App
+ textarea textarea.Model
+ attachments []message.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 {
@@ -56,6 +64,18 @@ var editorMaps = EditorKeyMaps{
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{
@@ -69,7 +89,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
- key.WithHelp("ctrl+r+r", "delete all attchments"),
+ key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
@@ -132,6 +152,15 @@ func (m *editorCmp) send() tea.Cmd {
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
@@ -200,6 +229,67 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 := message.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
+ if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() {
+ // 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() {
+ // 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()
@@ -243,7 +333,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
- m.textarea.SetWidth(width)
return nil
}
@@ -314,7 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
- app: app,
- textarea: ta,
+ app: app,
+ textarea: ta,
+ history: []string{},
+ historyIndex: 0,
+ currentMessage: "",
}
}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/messages.go
index baa7c7e6d..d6f252aad 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/messages.go
@@ -386,6 +386,8 @@ func (m *messagesCmp) help() string {
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"),
)
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index f2dec7878..973b03ef1 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -71,8 +71,7 @@ func (m *sidebarCmp) View() string {
return baseStyle.
Width(m.width).
PaddingLeft(4).
- PaddingRight(2).
- Height(m.height - 1).
+ PaddingRight(1).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
@@ -98,14 +97,9 @@ func (m *sidebarCmp) sessionSection() string {
sessionValue := baseStyle.
Foreground(t.Text()).
- Width(m.width - lipgloss.Width(sessionKey)).
Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
- return lipgloss.JoinHorizontal(
- lipgloss.Left,
- sessionKey,
- sessionValue,
- )
+ return sessionKey + sessionValue
}
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
index b21f166ca..d919b5303 100644
--- a/internal/tui/components/dialog/models.go
+++ b/internal/tui/components/dialog/models.go
@@ -10,7 +10,6 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
- "github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -127,7 +126,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.switchProvider(1)
}
case key.Matches(msg, modelKeys.Enter):
- status.Info(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(CloseModelDialogMsg{})
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index d0468d307..5e5b09e1b 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/permission"
@@ -13,6 +14,7 @@ import (
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
+ "path/filepath"
"strings"
)
@@ -204,10 +206,19 @@ func (p *permissionDialogCmp) renderHeader() string {
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", p.permission.Path))
+ Render(fmt.Sprintf(": %s", relativePath))
headerParts := []string{
lipgloss.JoinHorizontal(
diff --git a/internal/tui/components/dialog/tools.go b/internal/tui/components/dialog/tools.go
new file mode 100644
index 000000000..76e6ff227
--- /dev/null
+++ b/internal/tui/components/dialog/tools.go
@@ -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/tui/components/util"
+ "github.com/sst/opencode/internal/tui/layout"
+ "github.com/sst/opencode/internal/tui/styles"
+ "github.com/sst/opencode/internal/tui/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,
+ }
+} \ No newline at end of file
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 701361bb4..bc59fdc6f 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -84,7 +84,7 @@ func (i *detailCmp) updateContent() {
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(messageStyle.Render("Message:"))
content.WriteString("\n")
- content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
+ content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.Message))
content.WriteString("\n\n")
// Attributes section
@@ -112,7 +112,7 @@ func (i *detailCmp) updateContent() {
valueStyle.Render(value),
)
- content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
+ content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(attrLine))
content.WriteString("\n")
}
}
@@ -123,7 +123,7 @@ func (i *detailCmp) updateContent() {
content.WriteString("\n")
content.WriteString(sessionStyle.Render("Session:"))
content.WriteString("\n")
- content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.SessionID))
+ content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.SessionID))
}
i.viewport.SetContent(content.String())
diff --git a/internal/tui/components/spinner/spinner.go b/internal/tui/components/spinner/spinner.go
new file mode 100644
index 000000000..5e1af8771
--- /dev/null
+++ b/internal/tui/components/spinner/spinner.go
@@ -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
+} \ No newline at end of file
diff --git a/internal/tui/components/spinner/spinner_test.go b/internal/tui/components/spinner/spinner_test.go
new file mode 100644
index 000000000..065726e91
--- /dev/null
+++ b/internal/tui/components/spinner/spinner_test.go
@@ -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
+} \ No newline at end of file
diff --git a/internal/tui/image/clipboard_unix.go b/internal/tui/image/clipboard_unix.go
new file mode 100644
index 000000000..3cb590207
--- /dev/null
+++ b/internal/tui/image/clipboard_unix.go
@@ -0,0 +1,49 @@
+//go:build !windows
+
+package image
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "github.com/atotto/clipboard"
+)
+
+func GetImageFromClipboard() ([]byte, string, error) {
+ text, err := clipboard.ReadAll()
+ if err != nil {
+ return nil, "", fmt.Errorf("Error reading clipboard")
+ }
+
+ if text == "" {
+ return nil, "", nil
+ }
+
+ binaryData := []byte(text)
+ imageBytes, err := binaryToImage(binaryData)
+ if err != nil {
+ return nil, text, nil
+ }
+ return imageBytes, "", nil
+
+}
+
+
+
+func binaryToImage(data []byte) ([]byte, error) {
+ reader := bytes.NewReader(data)
+ img, _, err := image.Decode(reader)
+ if err != nil {
+ return nil, fmt.Errorf("Unable to covert bytes to image")
+ }
+
+ return ImageToBytes(img)
+}
+
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/tui/image/clipboard_windows.go b/internal/tui/image/clipboard_windows.go
new file mode 100644
index 000000000..6431ce3d4
--- /dev/null
+++ b/internal/tui/image/clipboard_windows.go
@@ -0,0 +1,192 @@
+//go:build windows
+
+package image
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "log/slog"
+ "syscall"
+ "unsafe"
+)
+
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
+ openClipboard = user32.NewProc("OpenClipboard")
+ closeClipboard = user32.NewProc("CloseClipboard")
+ getClipboardData = user32.NewProc("GetClipboardData")
+ isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
+ globalLock = kernel32.NewProc("GlobalLock")
+ globalUnlock = kernel32.NewProc("GlobalUnlock")
+ globalSize = kernel32.NewProc("GlobalSize")
+)
+
+const (
+ CF_TEXT = 1
+ CF_UNICODETEXT = 13
+ CF_DIB = 8
+)
+
+type BITMAPINFOHEADER struct {
+ BiSize uint32
+ BiWidth int32
+ BiHeight int32
+ BiPlanes uint16
+ BiBitCount uint16
+ BiCompression uint32
+ BiSizeImage uint32
+ BiXPelsPerMeter int32
+ BiYPelsPerMeter int32
+ BiClrUsed uint32
+ BiClrImportant uint32
+}
+
+func GetImageFromClipboard() ([]byte, string, error) {
+ ret, _, _ := openClipboard.Call(0)
+ if ret == 0 {
+ return nil, "", fmt.Errorf("failed to open clipboard")
+ }
+ defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
+ _, _, err := closeClipboard.Call(a...)
+ if err != nil {
+ slog.Error("close clipboard failed")
+ return
+ }
+ }(closeClipboard)
+ isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
+ isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
+
+ if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
+ // Get text from clipboard
+ var formatToUse uintptr = CF_TEXT
+ if isUnicodeTextAvailable != 0 {
+ formatToUse = CF_UNICODETEXT
+ }
+
+ hClipboardText, _, _ := getClipboardData.Call(formatToUse)
+ if hClipboardText != 0 {
+ textPtr, _, _ := globalLock.Call(hClipboardText)
+ if textPtr != 0 {
+ defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
+ _, _, err := globalUnlock.Call(a...)
+ if err != nil {
+ slog.Error("Global unlock failed")
+ return
+ }
+ }(globalUnlock, hClipboardText)
+
+ // Get clipboard text
+ var clipboardText string
+ if formatToUse == CF_UNICODETEXT {
+ // Convert wide string to Go string
+ clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
+ } else {
+ // Get size of ANSI text
+ size, _, _ := globalSize.Call(hClipboardText)
+ if size > 0 {
+ // Convert ANSI string to Go string
+ textBytes := make([]byte, size)
+ copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
+ clipboardText = bytesToString(textBytes)
+ }
+ }
+
+ // Check if the text is not empty
+ if clipboardText != "" {
+ return nil, clipboardText, nil
+ }
+ }
+ }
+ }
+ hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
+ if hClipboardData == 0 {
+ return nil, "", fmt.Errorf("failed to get clipboard data")
+ }
+
+ dataPtr, _, _ := globalLock.Call(hClipboardData)
+ if dataPtr == 0 {
+ return nil, "", fmt.Errorf("failed to lock clipboard data")
+ }
+ defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
+ _, _, err := globalUnlock.Call(a...)
+ if err != nil {
+ slog.Error("Global unlock failed")
+ return
+ }
+ }(globalUnlock, hClipboardData)
+
+ bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
+
+ width := int(bmiHeader.BiWidth)
+ height := int(bmiHeader.BiHeight)
+ if height < 0 {
+ height = -height
+ }
+ bitsPerPixel := int(bmiHeader.BiBitCount)
+
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ var bitsOffset uintptr
+ if bitsPerPixel <= 8 {
+ numColors := uint32(1) << bitsPerPixel
+ if bmiHeader.BiClrUsed > 0 {
+ numColors = bmiHeader.BiClrUsed
+ }
+ bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
+ } else {
+ bitsOffset = unsafe.Sizeof(*bmiHeader)
+ }
+
+ for y := range height {
+ for x := range width {
+
+ srcY := height - y - 1
+ if bmiHeader.BiHeight < 0 {
+ srcY = y
+ }
+
+ var pixelPointer unsafe.Pointer
+ var r, g, b, a uint8
+
+ switch bitsPerPixel {
+ case 24:
+ stride := (width*3 + 3) &^ 3
+ pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
+ b = *(*byte)(pixelPointer)
+ g = *(*byte)(unsafe.Add(pixelPointer, 1))
+ r = *(*byte)(unsafe.Add(pixelPointer, 2))
+ a = 255
+ case 32:
+ pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
+ b = *(*byte)(pixelPointer)
+ g = *(*byte)(unsafe.Add(pixelPointer, 1))
+ r = *(*byte)(unsafe.Add(pixelPointer, 2))
+ a = *(*byte)(unsafe.Add(pixelPointer, 3))
+ if a == 0 {
+ a = 255
+ }
+ default:
+ return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
+ }
+
+ img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
+ }
+ }
+
+ imageBytes, err := ImageToBytes(img)
+ if err != nil {
+ return nil, "", err
+ }
+ return imageBytes, "", nil
+}
+
+func bytesToString(b []byte) string {
+ i := bytes.IndexByte(b, 0)
+ if i == -1 {
+ return string(b)
+ }
+ return string(b[:i])
+}
diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go
index b55884d11..f476b201c 100644
--- a/internal/tui/image/images.go
+++ b/internal/tui/image/images.go
@@ -1,8 +1,10 @@
package image
import (
+ "bytes"
"fmt"
"image"
+ "image/png"
"os"
"strings"
@@ -71,3 +73,13 @@ func ImagePreview(width int, filename string) (string, error) {
return imageString, nil
}
+
+func ImageToBytes(image image.Image) ([]byte, error) {
+ buf := new(bytes.Buffer)
+ err := png.Encode(buf, image)
+ if err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index 08b10fdd6..b5bdca20a 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -11,16 +11,16 @@ type Container interface {
tea.Model
Sizeable
Bindings
- Focus() // Add focus method
- Blur() // Add blur method
+ Focus()
+ Blur()
}
+
type container struct {
width int
height int
content tea.Model
- // Style options
paddingTop int
paddingRight int
paddingBottom int
@@ -32,7 +32,7 @@ type container struct {
borderLeft bool
borderStyle lipgloss.Border
- focused bool // Track focus state
+ focused bool
}
func (c *container) Init() tea.Cmd {
@@ -152,16 +152,13 @@ func (c *container) Blur() {
type ContainerOption func(*container)
func NewContainer(content tea.Model, options ...ContainerOption) Container {
-
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
}
-
for _, option := range options {
option(c)
}
-
return c
}
diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go
index 59d8c8644..5a5c791fb 100644
--- a/internal/tui/theme/manager.go
+++ b/internal/tui/theme/manager.go
@@ -202,6 +202,54 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
theme.DiffAddedLineNumberBgColor = adaptiveColor
case "diffremovedlinenumberbg":
theme.DiffRemovedLineNumberBgColor = adaptiveColor
+ case "syntaxcomment":
+ theme.SyntaxCommentColor = adaptiveColor
+ case "syntaxkeyword":
+ theme.SyntaxKeywordColor = adaptiveColor
+ case "syntaxfunction":
+ theme.SyntaxFunctionColor = adaptiveColor
+ case "syntaxvariable":
+ theme.SyntaxVariableColor = adaptiveColor
+ case "syntaxstring":
+ theme.SyntaxStringColor = adaptiveColor
+ case "syntaxnumber":
+ theme.SyntaxNumberColor = adaptiveColor
+ case "syntaxtype":
+ theme.SyntaxTypeColor = adaptiveColor
+ case "syntaxoperator":
+ theme.SyntaxOperatorColor = adaptiveColor
+ case "syntaxpunctuation":
+ theme.SyntaxPunctuationColor = adaptiveColor
+ case "markdowntext":
+ theme.MarkdownTextColor = adaptiveColor
+ case "markdownheading":
+ theme.MarkdownHeadingColor = adaptiveColor
+ case "markdownlink":
+ theme.MarkdownLinkColor = adaptiveColor
+ case "markdownlinktext":
+ theme.MarkdownLinkTextColor = adaptiveColor
+ case "markdowncode":
+ theme.MarkdownCodeColor = adaptiveColor
+ case "markdownblockquote":
+ theme.MarkdownBlockQuoteColor = adaptiveColor
+ case "markdownemph":
+ theme.MarkdownEmphColor = adaptiveColor
+ case "markdownstrong":
+ theme.MarkdownStrongColor = adaptiveColor
+ case "markdownhorizontalrule":
+ theme.MarkdownHorizontalRuleColor = adaptiveColor
+ case "markdownlistitem":
+ theme.MarkdownListItemColor = adaptiveColor
+ case "markdownlistitemenum":
+ theme.MarkdownListEnumerationColor = adaptiveColor
+ case "markdownimage":
+ theme.MarkdownImageColor = adaptiveColor
+ case "markdownimagetext":
+ theme.MarkdownImageTextColor = adaptiveColor
+ case "markdowncodeblock":
+ theme.MarkdownCodeBlockColor = adaptiveColor
+ case "markdownlistenumeration":
+ theme.MarkdownListEnumerationColor = adaptiveColor
default:
slog.Warn("Unknown color key in custom theme", "key", key)
}
diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go
index fffd316ba..c97b95478 100644
--- a/internal/tui/theme/theme.go
+++ b/internal/tui/theme/theme.go
@@ -235,7 +235,19 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
}, nil
}
- // Case 2: Map with dark and light keys
+ // Case 2: Int value between 0 and 255
+ if numericVal, ok := value.(float64); ok {
+ intVal := int(numericVal)
+ if intVal < 0 || intVal > 255 {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid int color value (must be between 0 and 255): %d", intVal)
+ }
+ return lipgloss.AdaptiveColor{
+ Dark: fmt.Sprintf("%d", intVal),
+ Light: fmt.Sprintf("%d", intVal),
+ }, nil
+ }
+
+ // Case 3: Map with dark and light keys
if colorMap, ok := value.(map[string]any); ok {
darkVal, darkOk := colorMap["dark"]
lightVal, lightOk := colorMap["light"]
@@ -248,7 +260,20 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
lightHex, lightIsString := lightVal.(string)
if !darkIsString || !lightIsString {
- return lipgloss.AdaptiveColor{}, fmt.Errorf("color values must be strings")
+ darkVal, darkIsNumber := darkVal.(float64)
+ lightVal, lightIsNumber := lightVal.(float64)
+
+ if !darkIsNumber || !lightIsNumber {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("color map values must be strings or ints")
+ }
+
+ darkInt := int(darkVal)
+ lightInt := int(lightVal)
+
+ return lipgloss.AdaptiveColor{
+ Dark: fmt.Sprintf("%d", darkInt),
+ Light: fmt.Sprintf("%d", lightInt),
+ }, nil
}
if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index cad64d30f..56be04619 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
+ "github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
@@ -38,6 +39,7 @@ type keyMap struct {
Filepicker key.Binding
Models key.Binding
SwitchTheme key.Binding
+ Tools key.Binding
}
const (
@@ -81,6 +83,11 @@ var keys = keyMap{
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch theme"),
),
+
+ Tools: key.NewBinding(
+ key.WithKeys("f9"),
+ key.WithHelp("f9", "show available tools"),
+ ),
}
var helpEsc = key.NewBinding(
@@ -137,6 +144,9 @@ type appModel struct {
showMultiArgumentsDialog bool
multiArgumentsDialog dialog.MultiArgumentsDialogCmp
+
+ showToolsDialog bool
+ toolsDialog dialog.ToolsDialog
}
func (a appModel) Init() tea.Cmd {
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, cmd)
cmd = a.themeDialog.Init()
cmds = append(cmds, cmd)
+ cmd = a.toolsDialog.Init()
+ cmds = append(cmds, cmd)
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
return a, nil
+
+ case dialog.CloseToolsDialogMsg:
+ a.showToolsDialog = false
+ return a, nil
+
+ case dialog.ShowToolsDialogMsg:
+ a.showToolsDialog = msg.Show
+ return a, nil
case dialog.ThemeChangedMsg:
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -397,6 +417,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
+ a.app.SetFilepickerOpen(a.showFilepicker)
}
if a.showModelDialog {
a.showModelDialog = false
@@ -404,9 +425,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showMultiArgumentsDialog {
a.showMultiArgumentsDialog = false
}
+ if a.showToolsDialog {
+ a.showToolsDialog = false
+ }
return a, nil
case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showModelDialog = false
+ a.showFilepicker = false
+
// Load sessions and show the dialog
sessions, err := a.app.Sessions.List(context.Background())
if err != nil {
@@ -424,6 +454,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
case key.Matches(msg, keys.Commands):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showModelDialog = false
+
// Show commands dialog
if len(a.commands) == 0 {
status.Warn("No commands available")
@@ -440,22 +474,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showFilepicker = false
+
a.showModelDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, keys.SwitchTheme):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showModelDialog = false
+ a.showFilepicker = false
+
a.showThemeDialog = true
return a, a.themeDialog.Init()
}
return a, nil
+ case key.Matches(msg, keys.Tools):
+ // Check if any other dialog is open
+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions &&
+ !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog &&
+ !a.showFilepicker && !a.showModelDialog && !a.showInitDialog &&
+ !a.showMultiArgumentsDialog {
+ // Toggle tools dialog
+ a.showToolsDialog = !a.showToolsDialog
+ if a.showToolsDialog {
+ // Get tool names dynamically
+ toolNames := getAvailableToolNames(a.app)
+ a.toolsDialog.SetTools(toolNames)
+ }
+ return a, nil
+ }
+ return a, nil
case key.Matches(msg, returnKey) || key.Matches(msg):
if msg.String() == quitKey {
if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage)
}
} else if !a.filepicker.IsCWDFocused() {
+ if a.showToolsDialog {
+ a.showToolsDialog = false
+ return a, nil
+ }
if a.showQuit {
a.showQuit = !a.showQuit
return a, nil
@@ -476,6 +540,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
+ a.app.SetFilepickerOpen(a.showFilepicker)
return a, nil
}
if a.currentPage == page.LogsPage {
@@ -490,6 +555,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
a.showHelp = !a.showHelp
+
+ // Close other dialogs if opening help
+ if a.showHelp {
+ a.showToolsDialog = false
+ }
return a, nil
case key.Matches(msg, helpEsc):
if a.app.PrimaryAgent.IsBusy() {
@@ -500,8 +570,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
case key.Matches(msg, keys.Filepicker):
+ // Toggle filepicker
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
+ a.app.SetFilepickerOpen(a.showFilepicker)
+
+ // Close other dialogs if opening filepicker
+ if a.showFilepicker {
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showModelDialog = false
+ a.showCommandDialog = false
+ a.showSessionDialog = false
+ }
return a, nil
}
@@ -600,6 +681,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
}
+
+ if a.showToolsDialog {
+ d, toolsCmd := a.toolsDialog.Update(msg)
+ a.toolsDialog = d.(dialog.ToolsDialog)
+ cmds = append(cmds, toolsCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
@@ -615,6 +706,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
+// getAvailableToolNames returns a list of all available tool names
+func getAvailableToolNames(app *app.App) []string {
+ // Get primary agent tools (which already include MCP tools)
+ allTools := agent.PrimaryAgentTools(
+ app.Permissions,
+ app.Sessions,
+ app.Messages,
+ app.History,
+ app.LSPClients,
+ )
+
+ // Extract tool names
+ var toolNames []string
+ for _, tool := range allTools {
+ toolNames = append(toolNames, tool.Info().Name)
+ }
+
+ return toolNames
+}
+
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
// Allow navigating to logs page even when agent is busy
if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
@@ -820,6 +931,21 @@ func (a appModel) View() string {
true,
)
}
+
+ if a.showToolsDialog {
+ overlay := a.toolsDialog.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
return appView
}
@@ -838,6 +964,7 @@ func New(app *app.App) tea.Model {
permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(),
+ toolsDialog: dialog.NewToolsDialogCmp(),
app: app,
commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{