From bc2faab745ce33db2737df186e89e6ff11497e5f Mon Sep 17 00:00:00 2001 From: Filip Hejmowski Date: Mon, 21 Jul 2025 12:26:58 +0200 Subject: [PATCH] fix(tui): allow character-by-character editing of TUI file attachments --- .../internal/components/dialog/complete.go | 101 +++++++++++------- .../internal/components/textarea/textarea.go | 28 +++++ packages/tui/internal/tui/tui.go | 28 +++-- 3 files changed, 108 insertions(+), 49 deletions(-) diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index f18d9751022c..ba719a30ca59 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -34,6 +34,7 @@ type CompletionDialog interface { tea.ViewModel SetWidth(width int) IsEmpty() bool + SetInitialText(text string) tea.Cmd } type completionDialogComponent struct { @@ -64,53 +65,58 @@ func (c *completionDialogComponent) Init() tea.Cmd { return nil } -func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd { - return func() tea.Msg { - allItems := make([]completions.CompletionSuggestion, 0) - providersWithResults := 0 +// buildCompletionList queries providers and builds a ranked list of completions +func (c *completionDialogComponent) buildCompletionList(query string) []completions.CompletionSuggestion { + allItems := make([]completions.CompletionSuggestion, 0) + providersWithResults := 0 + + // Collect results from all providers + for _, provider := range c.providers { + items, err := provider.GetChildEntries(query) + if err != nil { + slog.Error( + "Failed to get completion items", + "provider", + provider.GetId(), + "error", + err, + ) + continue + } + if len(items) > 0 { + providersWithResults++ + allItems = append(allItems, items...) + } + } - // Collect results from all providers - for _, provider := range c.providers { - items, err := provider.GetChildEntries(query) - if err != nil { - slog.Error( - "Failed to get completion items", - "provider", - provider.GetId(), - "error", - err, - ) - continue - } - if len(items) > 0 { - providersWithResults++ - allItems = append(allItems, items...) - } + // If there's a query, use fuzzy ranking to sort results + if query != "" && providersWithResults > 1 { + t := theme.CurrentTheme() + baseStyle := styles.NewStyle().Background(t.BackgroundElement()) + // Create a slice of display values for fuzzy matching + displayValues := make([]string, len(allItems)) + for i, item := range allItems { + displayValues[i] = item.Display(baseStyle) } - // If there's a query, use fuzzy ranking to sort results - if query != "" && providersWithResults > 1 { - t := theme.CurrentTheme() - baseStyle := styles.NewStyle().Background(t.BackgroundElement()) - // Create a slice of display values for fuzzy matching - displayValues := make([]string, len(allItems)) - for i, item := range allItems { - displayValues[i] = item.Display(baseStyle) - } + matches := fuzzy.RankFindFold(query, displayValues) + sort.Sort(matches) - matches := fuzzy.RankFindFold(query, displayValues) - sort.Sort(matches) + // Reorder items based on fuzzy ranking + rankedItems := make([]completions.CompletionSuggestion, 0, len(matches)) + for _, match := range matches { + rankedItems = append(rankedItems, allItems[match.OriginalIndex]) + } - // Reorder items based on fuzzy ranking - rankedItems := make([]completions.CompletionSuggestion, 0, len(matches)) - for _, match := range matches { - rankedItems = append(rankedItems, allItems[match.OriginalIndex]) - } + return rankedItems + } - return rankedItems - } + return allItems +} - return allItems +func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd { + return func() tea.Msg { + return c.buildCompletionList(query) } } func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -191,6 +197,21 @@ func (c *completionDialogComponent) IsEmpty() bool { return c.list.IsEmpty() } +func (c *completionDialogComponent) SetInitialText(text string) tea.Cmd { + c.pseudoSearchTextArea.SetValue(text) + query := strings.TrimPrefix(text, c.trigger) + c.query = query + + // Use the shared completion logic + allItems := c.buildCompletionList(query) + + // Update the list with filtered results immediately + c.list.SetItems(allItems) + + // Return focus command like the normal flow does + return c.pseudoSearchTextArea.Focus() +} + func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd { value := c.pseudoSearchTextArea.Value() return tea.Batch( diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index cc073e27d894..4c9dbb587cab 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -1567,6 +1567,34 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { break } if len(m.value[m.row]) > 0 && m.col > 0 { + if att, ok := m.value[m.row][m.col-1].(*Attachment); ok { + var filePath string + if att.Filename != "" { + filePath = att.Filename + } else { + filePath = strings.TrimPrefix(att.Display, "@") + } + + fullText := "@" + filePath + if len(fullText) > 1 { + fullText = fullText[:len(fullText)-1] + } + + m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) + + textRunes := []rune(fullText) + textInterfaces := make([]any, len(textRunes)) + for i, r := range textRunes { + textInterfaces[i] = r + } + m.value[m.row] = slices.Insert(m.value[m.row], m.col-1, textInterfaces...) + + m.SetCursorColumn(m.col - 1 + len(textRunes)) + + return m, func() tea.Msg { + return tea.KeyPressMsg{Text: fullText} + } + } m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) m.SetCursorColumn(m.col - 1) } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 901cee11da26..b02e5f8f5acf 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -167,20 +167,30 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Sequence(cmds...) } - // Handle file completions trigger - if keyString == "@" && + // Handle file completions trigger - both single @ and text starting with @ + if (keyString == "@" || strings.HasPrefix(msg.Text, "@")) && !a.showCompletionDialog { a.showCompletionDialog = true - updated, cmd := a.editor.Update(msg) - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) + if len(msg.Text) == 1 || keyString == "@" { + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + } - // Set both file and symbols providers for @ completion a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider) - updated, cmd = a.completions.Update(msg) - a.completions = updated.(dialog.CompletionDialog) - cmds = append(cmds, cmd) + + // If it's more than just "@", set the initial text and handle the returned command + if len(msg.Text) > 1 && strings.HasPrefix(msg.Text, "@") { + cmd := a.completions.SetInitialText(msg.Text) + cmds = append(cmds, cmd) + // Don't call Update - SetInitialText already handled the text and search + } else { + // Normal single "@" keypress - call Update to process the keypress + updated, cmd := a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + } return a, tea.Sequence(cmds...) }