feat(tui): file attachments

This commit is contained in:
adamdottv 2025-07-04 10:29:40 -05:00
parent 891ed6ebc0
commit f9abc7c84f
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
14 changed files with 794 additions and 407 deletions

View file

@ -37,6 +37,7 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // 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/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect

View file

@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=

View file

@ -44,7 +44,7 @@ type SessionClearedMsg struct{}
type CompactSessionMsg struct{} type CompactSessionMsg struct{}
type SendMsg struct { type SendMsg struct {
Text string Text string
Attachments []Attachment Attachments []opencode.FilePartParam
} }
type OptimisticMessageAddedMsg struct { type OptimisticMessageAddedMsg struct {
Message opencode.Message Message opencode.Message
@ -217,13 +217,6 @@ func getDefaultModel(
return nil return nil
} }
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
func (a *App) IsBusy() bool { func (a *App) IsBusy() bool {
if len(a.Messages) == 0 { if len(a.Messages) == 0 {
return false return false
@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil 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 var cmds []tea.Cmd
if a.Session.ID == "" { if a.Session.ID == "" {
session, err := a.CreateSession(ctx) session, err := a.CreateSession(ctx)
if err != nil { if err != nil {
return toast.NewErrorToast(err.Error()) return a, toast.NewErrorToast(err.Error())
} }
a.Session = session a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(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{ optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser, Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{ Parts: optimisticParts,
Type: opencode.MessagePartTypeText,
Text: text,
}},
Metadata: opencode.MessageMetadata{ Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID, SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{ 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, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg { 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{ _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F([]opencode.MessagePartUnionParam{ Parts: opencode.F(parts),
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}),
ProviderID: opencode.F(a.Provider.ID), ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.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 // The actual response will come through SSE
// For now, just return success // For now, just return success
return tea.Batch(cmds...) return a, tea.Batch(cmds...)
} }
func (a *App) Cancel(ctx context.Context, sessionID string) error { func (a *App) Cancel(ctx context.Context, sessionID string) error {

View file

@ -3,11 +3,14 @@ package chat
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath"
"strings" "strings"
"github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/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/app"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
@ -37,7 +40,6 @@ type EditorComponent interface {
type editorComponent struct { type editorComponent struct {
app *app.App app *app.App
textarea textarea.Model textarea textarea.Model
attachments []app.Attachment
spinner spinner.Model spinner spinner.Model
interruptKeyInDebounce bool interruptKeyInDebounce bool
} }
@ -66,17 +68,43 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = createSpinner() m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg: case dialog.CompletionSelectedMsg:
if msg.IsCommand { switch msg.ProviderID {
case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/") commandName := strings.TrimPrefix(msg.CompletionValue, "/")
updated, cmd := m.Clear() updated, cmd := m.Clear()
m = updated.(*editorComponent) m = updated.(*editorComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} else { case "files":
existingValue := m.textarea.Value() 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, " ") lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 { if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ") m.textarea.SetValue(msg.CompletionValue + " ")
@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) string {
if m.app.IsBusy() { if m.app.IsBusy() {
keyText := m.getInterruptKeyText() keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce { 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 { } else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt") 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 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() updated, cmd := m.Clear()
m = updated.(*editorComponent) m = updated.(*editorComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
attachments := m.attachments cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
m.attachments = nil
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
return m, tea.Batch(cmds...) 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) { func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard() _, text, err := image.GetImageFromClipboard()
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
return m, nil return m, nil
} }
if len(imageBytes) != 0 { // if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"} // attachment := app.Attachment{
m.attachments = append(m.attachments, attachment) // FilePath: attachmentName,
} else { // FileName: attachmentName,
m.textarea.SetValue(m.textarea.Value() + text) // Content: imageBytes,
} // MimeType: "image/png",
// }
// m.attachments = append(m.attachments, attachment)
// } else {
m.textarea.SetValue(m.textarea.Value() + text)
// }
return m, nil 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.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().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.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = 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.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.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.Styles.Cursor.Color = t.Primary()
ta.Prompt = " " ta.Prompt = " "

View file

@ -64,7 +64,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct { type CompletionSelectedMsg struct {
SearchString string SearchString string
CompletionValue string CompletionValue string
IsCommand bool ProviderID string
} }
type CompletionDialogCompleteItemMsg struct { type CompletionDialogCompleteItemMsg struct {
@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var query string var query string
query = c.pseudoSearchTextArea.Value() query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query { if query != c.query {
c.query = query c.query = query
@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string {
for _, cmd := range completions { for _, cmd := range completions {
title := cmd.DisplayValue() title := cmd.DisplayValue()
if len(title) > maxWidth-4 { width := lipgloss.Width(title)
maxWidth = len(title) + 4 if width > maxWidth-4 {
maxWidth = width + 4
} }
} }
@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value() value := c.pseudoSearchTextArea.Value()
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch( return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{ util.CmdHandler(CompletionSelectedMsg{
SearchString: value, SearchString: value,
CompletionValue: item.GetValue(), CompletionValue: item.GetValue(),
IsCommand: isCommand, ProviderID: c.completionProvider.GetId(),
}), }),
c.close(), c.close(),
) )

View file

@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string {
f.list.SetMaxWidth(f.width - 4) f.list.SetMaxWidth(f.width - 4)
inputView := f.textInput.View() inputView := f.textInput.View()
inputView = styles.NewStyle(). inputView = styles.NewStyle().
Background(t.BackgroundPanel()). Background(t.BackgroundElement()).
Height(1). Height(1).
Width(f.width-4). Width(f.width-4).
Padding(0, 0). Padding(0, 0).
@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd {
func createTextInput(existing *textinput.Model) textinput.Model { func createTextInput(existing *textinput.Model) textinput.Model {
t := theme.CurrentTheme() t := theme.CurrentTheme()
bgColor := t.BackgroundPanel() bgColor := t.BackgroundElement()
textColor := t.Text() textColor := t.Text()
textMutedColor := t.TextMuted() textMutedColor := t.TextMuted()

View file

@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName) displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle(). return styles.NewStyle().
Background(t.Primary()). Background(t.Primary()).
Foreground(t.BackgroundElement()). Foreground(t.BackgroundPanel()).
Width(width). Width(width).
PaddingLeft(1). PaddingLeft(1).
Render(displayText) Render(displayText)
} else { } else {
modelStyle := styles.NewStyle(). modelStyle := styles.NewStyle().
Foreground(t.Text()). Foreground(t.Text()).
Background(t.BackgroundElement()) Background(t.BackgroundPanel())
providerStyle := styles.NewStyle(). providerStyle := styles.NewStyle().
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Background(t.BackgroundElement()) Background(t.BackgroundPanel())
modelPart := modelStyle.Render(m.ModelName) modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName)) providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart combinedText := modelPart + providerPart
return styles.NewStyle(). return styles.NewStyle().
Background(t.BackgroundElement()). Background(t.BackgroundPanel()).
PaddingLeft(1). PaddingLeft(1).
Render(combinedText) Render(combinedText)
} }

View file

@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
return strings.Join(listItems, "\n") 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]{ return &listComponent[T]{
fallbackMsg: fallbackMsg, fallbackMsg: fallbackMsg,
items: items, items: items,
@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
} }
// NewStringList creates a new list component with string items // 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)) stringItems := make([]StringItem, len(items))
for i, item := range items { for i, item := range items {
stringItems[i] = StringItem(item) stringItems[i] = StringItem(item)

View file

@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4 innerWidth := outerWidth - 4
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()) baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
var finalContent string var finalContent string
if m.title != "" { if m.title != "" {
@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
modalView, modalView,
background, background,
layout.WithOverlayBorder(), layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.BorderActive()), layout.WithOverlayBorderColor(t.Primary()),
) )
} }

View file

@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
} }
// Create lipgloss style for QR code with theme colors // 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 var result strings.Builder

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -52,7 +52,9 @@ type appModel struct {
messages chat.MessagesComponent messages chat.MessagesComponent
completions dialog.CompletionDialog completions dialog.CompletionDialog
commandProvider dialog.CompletionProvider commandProvider dialog.CompletionProvider
fileProvider dialog.CompletionProvider
showCompletionDialog bool showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding leaderBinding *key.Binding
isLeaderSequence bool isLeaderSequence bool
toastManager *toast.ToastManager toastManager *toast.ToastManager
@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
!a.showCompletionDialog && !a.showCompletionDialog &&
a.editor.Value() == "" { a.editor.Value() == "" {
a.showCompletionDialog = true a.showCompletionDialog = true
a.fileCompletionActive = false
updated, cmd := a.editor.Update(msg) updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent) a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd) 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) updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog) a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showCompletionDialog { if a.showCompletionDialog {
switch keyString { switch keyString {
case "tab", "enter", "esc", "ctrl+c": case "tab", "enter", "esc", "ctrl+c", "up", "down":
updated, cmd := a.completions.Update(msg) updated, cmd := a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog) a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd) 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()) return a, toast.NewErrorToast(msg.Error())
case app.SendMsg: case app.SendMsg:
a.showCompletionDialog = false 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) cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg: case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false a.showCompletionDialog = false
a.fileCompletionActive = false
case opencode.EventListResponseEventInstallationUpdated: case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast( return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.", "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 return nil
} }
os.Remove(tmpfile.Name()) os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{ return app.SendMsg{
Text: string(content), Text: string(content),
Attachments: []app.Attachment{}, // attachments,
} }
}) })
cmds = append(cmds, cmd) 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 { func NewModel(app *app.App) tea.Model {
commandProvider := completions.NewCommandCompletionProvider(app) commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileAndFolderContextGroup(app)
messages := chat.NewMessagesComponent(app) messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app) editor := chat.NewEditorComponent(app)
@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model {
messages: messages, messages: messages,
completions: completions, completions: completions,
commandProvider: commandProvider, commandProvider: commandProvider,
fileProvider: fileProvider,
leaderBinding: leaderBinding, leaderBinding: leaderBinding,
isLeaderSequence: false, isLeaderSequence: false,
showCompletionDialog: false, showCompletionDialog: false,
fileCompletionActive: false,
toastManager: toast.NewToastManager(), toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle, interruptKeyState: InterruptKeyIdle,
fileViewer: fileviewer.New(app), fileViewer: fileviewer.New(app),