adamdottv 7 месяцев назад
Родитель
Сommit
f9abc7c84f

+ 1 - 0
packages/tui/go.mod

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

+ 2 - 0
packages/tui/go.sum

@@ -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 - 23
packages/tui/internal/app/app.go

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

+ 84 - 20
packages/tui/internal/components/chat/editor.go

@@ -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 = " "

+ 5 - 10
packages/tui/internal/components/dialog/complete.go

@@ -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(),
 	)

+ 2 - 2
packages/tui/internal/components/dialog/find.go

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

+ 4 - 4
packages/tui/internal/components/dialog/models.go

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

+ 12 - 2
packages/tui/internal/components/list/list.go

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

+ 2 - 2
packages/tui/internal/components/modal/modal.go

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

+ 1 - 1
packages/tui/internal/components/qr/qr.go

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

Разница между файлами не показана из-за своего большого размера
+ 537 - 154
packages/tui/internal/components/textarea/textarea.go


+ 0 - 41
packages/tui/internal/layout/flex_example_test.go

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

+ 0 - 90
packages/tui/internal/layout/flex_test.go

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

+ 31 - 6
packages/tui/internal/tui/tui.go

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

Некоторые файлы не были показаны из-за большого количества измененных файлов