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/components | |
| parent | 4526b14b17dc49f3ef4f3b1a1d02eff5c6b6b59f (diff) | |
| parent | dff8e77eb6d1709fa1ddeb52d0d9c19afd13d385 (diff) | |
| download | opencode-9049295cc961b250be6144585dde322e778534d7.tar.gz opencode-9049295cc961b250be6144585dde322e778534d7.zip | |
Merge branch 'dev' into docs
Diffstat (limited to 'internal/tui/components')
| -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 |
9 files changed, 450 insertions, 24 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 |
