diff options
| author | Jay V <[email protected]> | 2025-05-21 15:01:25 -0400 |
|---|---|---|
| committer | Jay V <[email protected]> | 2025-05-21 15:01:25 -0400 |
| commit | 9049295cc961b250be6144585dde322e778534d7 (patch) | |
| tree | c8a2f09ed6cea54eb9587243eb7dbe298fef1b20 /internal/tui | |
| parent | 4526b14b17dc49f3ef4f3b1a1d02eff5c6b6b59f (diff) | |
| parent | dff8e77eb6d1709fa1ddeb52d0d9c19afd13d385 (diff) | |
| download | opencode-9049295cc961b250be6144585dde322e778534d7.tar.gz opencode-9049295cc961b250be6144585dde322e778534d7.zip | |
Merge branch 'dev' into docs
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/components/chat/editor.go | 112 | ||||
| -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.go | 10 | ||||
| -rw-r--r-- | internal/tui/components/dialog/models.go | 2 | ||||
| -rw-r--r-- | internal/tui/components/dialog/permission.go | 13 | ||||
| -rw-r--r-- | internal/tui/components/dialog/tools.go | 178 | ||||
| -rw-r--r-- | internal/tui/components/logs/details.go | 6 | ||||
| -rw-r--r-- | internal/tui/components/spinner/spinner.go | 127 | ||||
| -rw-r--r-- | internal/tui/components/spinner/spinner_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/image/clipboard_unix.go | 49 | ||||
| -rw-r--r-- | internal/tui/image/clipboard_windows.go | 192 | ||||
| -rw-r--r-- | internal/tui/image/images.go | 12 | ||||
| -rw-r--r-- | internal/tui/layout/container.go | 11 | ||||
| -rw-r--r-- | internal/tui/theme/manager.go | 48 | ||||
| -rw-r--r-- | internal/tui/theme/theme.go | 29 | ||||
| -rw-r--r-- | internal/tui/tui.go | 127 |
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{ |
