mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
feat(tui): file attachments
This commit is contained in:
parent
891ed6ebc0
commit
f9abc7c84f
14 changed files with 794 additions and 407 deletions
|
@ -37,6 +37,7 @@ require (
|
|||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
|
|
|
@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
|
|
|
@ -44,7 +44,7 @@ type SessionClearedMsg struct{}
|
|||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []Attachment
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.Message
|
||||
|
@ -217,13 +217,6 @@ func getDefaultModel(
|
|||
return nil
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
MimeType string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
func (a *App) IsBusy() bool {
|
||||
if len(a.Messages) == 0 {
|
||||
return false
|
||||
|
@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
|||
return session, nil
|
||||
}
|
||||
|
||||
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
|
||||
func (a *App) SendChatMessage(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
attachments []opencode.FilePartParam,
|
||||
) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
return toast.NewErrorToast(err.Error())
|
||||
return a, toast.NewErrorToast(err.Error())
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
optimisticParts := []opencode.MessagePart{{
|
||||
Type: opencode.MessagePartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
optimisticParts = append(optimisticParts, opencode.MessagePart{
|
||||
Type: opencode.MessagePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
MediaType: attachment.MediaType.Value,
|
||||
URL: attachment.URL.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
optimisticMessage := opencode.Message{
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.MessageRoleUser,
|
||||
Parts: []opencode.MessagePart{{
|
||||
Type: opencode.MessagePartTypeText,
|
||||
Text: text,
|
||||
}},
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.MessageRoleUser,
|
||||
Parts: optimisticParts,
|
||||
Metadata: opencode.MessageMetadata{
|
||||
SessionID: a.Session.ID,
|
||||
Time: opencode.MessageMetadataTime{
|
||||
|
@ -326,13 +335,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
parts := []opencode.MessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
parts = append(parts, opencode.FilePartParam{
|
||||
MediaType: attachment.MediaType,
|
||||
Type: attachment.Type,
|
||||
URL: attachment.URL,
|
||||
Filename: attachment.Filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F([]opencode.MessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}),
|
||||
Parts: opencode.F(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
|
@ -346,7 +367,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||
|
||||
// The actual response will come through SSE
|
||||
// For now, just return success
|
||||
return tea.Batch(cmds...)
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
|
|
|
@ -3,11 +3,14 @@ package chat
|
|||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
|
@ -37,7 +40,6 @@ type EditorComponent interface {
|
|||
type editorComponent struct {
|
||||
app *app.App
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
}
|
||||
|
@ -66,17 +68,43 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||
case dialog.CompletionSelectedMsg:
|
||||
if msg.IsCommand {
|
||||
switch msg.ProviderID {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
} else {
|
||||
existingValue := m.textarea.Value()
|
||||
case "files":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.CompletionValue + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Replace the current token (after last space)
|
||||
// The range to replace is from the '@' up to the current cursor position.
|
||||
// Replace the search term (e.g., "@search") with an empty string first.
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
// Now, insert the attachment at the position where the '@' was.
|
||||
// The cursor is now at `atIndex` after the replacement.
|
||||
filePath := msg.CompletionValue
|
||||
fileName := filepath.Base(filePath)
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + fileName,
|
||||
URL: fmt.Sprintf("file://%s", filePath),
|
||||
Filename: fileName,
|
||||
MediaType: "text/plain",
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
existingValue := m.textarea.Value()
|
||||
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||
if lastSpaceIndex == -1 {
|
||||
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||
|
@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) string {
|
|||
if m.app.IsBusy() {
|
||||
keyText := m.getInterruptKeyText()
|
||||
if m.interruptKeyInDebounce {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
|
||||
hint = muted(
|
||||
"working",
|
||||
) + m.spinner.View() + muted(
|
||||
" ",
|
||||
) + base(
|
||||
keyText+" again",
|
||||
) + muted(
|
||||
" interrupt",
|
||||
)
|
||||
} else {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||
}
|
||||
|
@ -195,14 +231,23 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
attachments := m.textarea.GetAttachments()
|
||||
fileParts := make([]opencode.FilePartParam, 0)
|
||||
for _, attachment := range attachments {
|
||||
fileParts = append(fileParts, opencode.FilePartParam{
|
||||
Type: opencode.F(opencode.FilePartTypeFile),
|
||||
MediaType: opencode.F(attachment.MediaType),
|
||||
URL: opencode.F(attachment.URL),
|
||||
Filename: opencode.F(attachment.Filename),
|
||||
})
|
||||
}
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
attachments := m.attachments
|
||||
m.attachments = nil
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
|
@ -212,18 +257,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
_, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if len(imageBytes) != 0 {
|
||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
||||
m.attachments = append(m.attachments, attachment)
|
||||
} else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
}
|
||||
// if len(imageBytes) != 0 {
|
||||
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
// attachment := app.Attachment{
|
||||
// FilePath: attachmentName,
|
||||
// FileName: attachmentName,
|
||||
// Content: imageBytes,
|
||||
// MimeType: "image/png",
|
||||
// }
|
||||
// m.attachments = append(m.attachments, attachment)
|
||||
// } else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
// }
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
@ -254,12 +304,26 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||
|
||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Attachment = styles.NewStyle().
|
||||
Foreground(t.Secondary()).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.SelectedAttachment = styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.Secondary()).
|
||||
Lipgloss()
|
||||
ta.Styles.Cursor.Color = t.Primary()
|
||||
|
||||
ta.Prompt = " "
|
||||
|
|
|
@ -64,7 +64,7 @@ type CompletionProvider interface {
|
|||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
IsCommand bool
|
||||
ProviderID string
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
|
@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
var query string
|
||||
query = c.pseudoSearchTextArea.Value()
|
||||
if query != "" {
|
||||
query = query[1:]
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
|
@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string {
|
|||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
width := lipgloss.Width(title)
|
||||
if width > maxWidth-4 {
|
||||
maxWidth = width + 4
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
|||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
ProviderID: c.completionProvider.GetId(),
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
|
|
|
@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string {
|
|||
f.list.SetMaxWidth(f.width - 4)
|
||||
inputView := f.textInput.View()
|
||||
inputView = styles.NewStyle().
|
||||
Background(t.BackgroundPanel()).
|
||||
Background(t.BackgroundElement()).
|
||||
Height(1).
|
||||
Width(f.width-4).
|
||||
Padding(0, 0).
|
||||
|
@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd {
|
|||
|
||||
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundPanel()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
|
|
|
@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
|
|||
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
||||
return styles.NewStyle().
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Foreground(t.BackgroundPanel()).
|
||||
Width(width).
|
||||
PaddingLeft(1).
|
||||
Render(displayText)
|
||||
} else {
|
||||
modelStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement())
|
||||
Background(t.BackgroundPanel())
|
||||
providerStyle := styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundElement())
|
||||
Background(t.BackgroundPanel())
|
||||
|
||||
modelPart := modelStyle.Render(m.ModelName)
|
||||
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
||||
|
||||
combinedText := modelPart + providerPart
|
||||
return styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingLeft(1).
|
||||
Render(combinedText)
|
||||
}
|
||||
|
|
|
@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
|
|||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
func NewListComponent[T ListItem](
|
||||
items []T,
|
||||
maxVisibleItems int,
|
||||
fallbackMsg string,
|
||||
useAlphaNumericKeys bool,
|
||||
) List[T] {
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: fallbackMsg,
|
||||
items: items,
|
||||
|
@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
|
|||
}
|
||||
|
||||
// NewStringList creates a new list component with string items
|
||||
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
|
||||
func NewStringList(
|
||||
items []string,
|
||||
maxVisibleItems int,
|
||||
fallbackMsg string,
|
||||
useAlphaNumericKeys bool,
|
||||
) List[StringItem] {
|
||||
stringItems := make([]StringItem, len(items))
|
||||
for i, item := range items {
|
||||
stringItems[i] = StringItem(item)
|
||||
|
|
|
@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||
|
||||
innerWidth := outerWidth - 4
|
||||
|
||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
|
||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
|
||||
|
||||
var finalContent string
|
||||
if m.title != "" {
|
||||
|
@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
|
|||
}
|
||||
|
||||
// Create lipgloss style for QR code with theme colors
|
||||
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
|
||||
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +0,0 @@
|
|||
package layout_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
)
|
||||
|
||||
func ExampleRender_withGap() {
|
||||
// Create a horizontal layout with 3px gap between items
|
||||
result := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Width: 30,
|
||||
Height: 1,
|
||||
Gap: 3,
|
||||
},
|
||||
layout.FlexItem{View: "Item1"},
|
||||
layout.FlexItem{View: "Item2"},
|
||||
layout.FlexItem{View: "Item3"},
|
||||
)
|
||||
fmt.Println(result)
|
||||
// Output: Item1 Item2 Item3
|
||||
}
|
||||
|
||||
func ExampleRender_withGapAndJustify() {
|
||||
// Create a horizontal layout with gap and space-between justification
|
||||
result := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Width: 30,
|
||||
Height: 1,
|
||||
Gap: 2,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
},
|
||||
layout.FlexItem{View: "A"},
|
||||
layout.FlexItem{View: "B"},
|
||||
layout.FlexItem{View: "C"},
|
||||
)
|
||||
fmt.Println(result)
|
||||
// Output: A B C
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlexGap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts FlexOptions
|
||||
items []FlexItem
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Row with gap",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 20,
|
||||
Height: 1,
|
||||
Gap: 2,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "A B C",
|
||||
},
|
||||
{
|
||||
name: "Column with gap",
|
||||
opts: FlexOptions{
|
||||
Direction: Column,
|
||||
Width: 1,
|
||||
Height: 5,
|
||||
Gap: 1,
|
||||
Align: AlignStart,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A", FixedSize: 1},
|
||||
{View: "B", FixedSize: 1},
|
||||
{View: "C", FixedSize: 1},
|
||||
},
|
||||
expected: "A\n \nB\n \nC",
|
||||
},
|
||||
{
|
||||
name: "Row with gap and justify space between",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 15,
|
||||
Height: 1,
|
||||
Gap: 1,
|
||||
Justify: JustifySpaceBetween,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "A B C",
|
||||
},
|
||||
{
|
||||
name: "No gap specified",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 10,
|
||||
Height: 1,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "ABC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Render(tt.opts, tt.items...)
|
||||
// Trim any trailing spaces for comparison
|
||||
result = strings.TrimRight(result, " ")
|
||||
expected := strings.TrimRight(tt.expected, " ")
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Render() = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -52,7 +52,9 @@ type appModel struct {
|
|||
messages chat.MessagesComponent
|
||||
completions dialog.CompletionDialog
|
||||
commandProvider dialog.CompletionProvider
|
||||
fileProvider dialog.CompletionProvider
|
||||
showCompletionDialog bool
|
||||
fileCompletionActive bool
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
|
@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
!a.showCompletionDialog &&
|
||||
a.editor.Value() == "" {
|
||||
a.showCompletionDialog = true
|
||||
a.fileCompletionActive = false
|
||||
|
||||
updated, cmd := a.editor.Update(msg)
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set command provider for command completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return a, tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
// Handle file completions trigger
|
||||
if keyString == "@" &&
|
||||
!a.showCompletionDialog {
|
||||
a.showCompletionDialog = true
|
||||
a.fileCompletionActive = true
|
||||
|
||||
updated, cmd := a.editor.Update(msg)
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set file provider for file completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
if a.showCompletionDialog {
|
||||
switch keyString {
|
||||
case "tab", "enter", "esc", "ctrl+c":
|
||||
case "tab", "enter", "esc", "ctrl+c", "up", "down":
|
||||
updated, cmd := a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return a, toast.NewErrorToast(msg.Error())
|
||||
case app.SendMsg:
|
||||
a.showCompletionDialog = false
|
||||
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
a.showCompletionDialog = false
|
||||
a.fileCompletionActive = false
|
||||
case opencode.EventListResponseEventInstallationUpdated:
|
||||
return a, toast.NewSuccessToast(
|
||||
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
||||
|
@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
// attachments := m.attachments
|
||||
// m.attachments = nil
|
||||
return app.SendMsg{
|
||||
Text: string(content),
|
||||
Attachments: []app.Attachment{}, // attachments,
|
||||
Text: string(content),
|
||||
}
|
||||
})
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
|
||||
func NewModel(app *app.App) tea.Model {
|
||||
commandProvider := completions.NewCommandCompletionProvider(app)
|
||||
fileProvider := completions.NewFileAndFolderContextGroup(app)
|
||||
|
||||
messages := chat.NewMessagesComponent(app)
|
||||
editor := chat.NewEditorComponent(app)
|
||||
|
@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model {
|
|||
messages: messages,
|
||||
completions: completions,
|
||||
commandProvider: commandProvider,
|
||||
fileProvider: fileProvider,
|
||||
leaderBinding: leaderBinding,
|
||||
isLeaderSequence: false,
|
||||
showCompletionDialog: false,
|
||||
fileCompletionActive: false,
|
||||
toastManager: toast.NewToastManager(),
|
||||
interruptKeyState: InterruptKeyIdle,
|
||||
fileViewer: fileviewer.New(app),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue