summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-04 10:29:40 -0500
committeradamdottv <[email protected]>2025-07-04 10:55:02 -0500
commitf9abc7c84f2544f5844d795bf835064114734817 (patch)
tree9719b89b2477e53021aad5fe94c5d465d3f5362b
parent891ed6ebc006703d5a26f89ecc85bd86f9b2133e (diff)
downloadopencode-f9abc7c84f2544f5844d795bf835064114734817.tar.gz
opencode-f9abc7c84f2544f5844d795bf835064114734817.zip
feat(tui): file attachments
-rw-r--r--packages/tui/go.mod1
-rw-r--r--packages/tui/go.sum2
-rw-r--r--packages/tui/internal/app/app.go67
-rw-r--r--packages/tui/internal/components/chat/editor.go104
-rw-r--r--packages/tui/internal/components/dialog/complete.go15
-rw-r--r--packages/tui/internal/components/dialog/find.go4
-rw-r--r--packages/tui/internal/components/dialog/models.go8
-rw-r--r--packages/tui/internal/components/list/list.go14
-rw-r--r--packages/tui/internal/components/modal/modal.go4
-rw-r--r--packages/tui/internal/components/qr/qr.go2
-rw-r--r--packages/tui/internal/components/textarea/textarea.go812
-rw-r--r--packages/tui/internal/layout/flex_example_test.go41
-rw-r--r--packages/tui/internal/layout/flex_test.go90
-rw-r--r--packages/tui/internal/tui/tui.go37
14 files changed, 794 insertions, 407 deletions
diff --git a/packages/tui/go.mod b/packages/tui/go.mod
index 043d9fcd1..74047af16 100644
--- a/packages/tui/go.mod
+++ b/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
diff --git a/packages/tui/go.sum b/packages/tui/go.sum
index 295482734..fdc5bbb01 100644
--- a/packages/tui/go.sum
+++ b/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=
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 6b59acaec..469857ab0 100644
--- a/packages/tui/internal/app/app.go
+++ b/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 {
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 669ef47d0..595fd4d57 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/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 = " "
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index caf754c71..7ba91dc5f 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/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(),
)
diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go
index 489b9f297..3fc6e5994 100644
--- a/packages/tui/internal/components/dialog/find.go
+++ b/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()
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
index 4ebf572eb..f8cda82a8 100644
--- a/packages/tui/internal/components/dialog/models.go
+++ b/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)
}
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
index a7ea3458d..16bc73ca5 100644
--- a/packages/tui/internal/components/list/list.go
+++ b/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)
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
index aa81a83e7..5c2fbf8bb 100644
--- a/packages/tui/internal/components/modal/modal.go
+++ b/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()),
)
}
diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go
index ccf28200b..233bcf524 100644
--- a/packages/tui/internal/components/qr/qr.go
+++ b/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
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
index 2ca08bb83..c2c92ea7f 100644
--- a/packages/tui/internal/components/textarea/textarea.go
+++ b/packages/tui/internal/components/textarea/textarea.go
@@ -9,6 +9,8 @@ import (
"time"
"unicode"
+ "slices"
+
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
@@ -17,7 +19,6 @@ import (
"github.com/charmbracelet/x/ansi"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
- "slices"
)
const (
@@ -32,6 +33,145 @@ const (
maxLines = 10000
)
+// Attachment represents a special object within the text, distinct from regular characters.
+type Attachment struct {
+ ID string // A unique identifier for this attachment instance
+ Display string // e.g., "@filename.txt"
+ URL string
+ Filename string
+ MediaType string
+}
+
+// Helper functions for converting between runes and any slices
+
+// runesToInterfaces converts a slice of runes to a slice of interfaces
+func runesToInterfaces(runes []rune) []any {
+ result := make([]any, len(runes))
+ for i, r := range runes {
+ result[i] = r
+ }
+ return result
+}
+
+// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes)
+func interfacesToRunes(items []any) []rune {
+ var result []rune
+ for _, item := range items {
+ switch val := item.(type) {
+ case rune:
+ result = append(result, val)
+ case *Attachment:
+ result = append(result, []rune(val.Display)...)
+ }
+ }
+ return result
+}
+
+// copyInterfaceSlice creates a copy of an any slice
+func copyInterfaceSlice(src []any) []any {
+ dst := make([]any, len(src))
+ copy(dst, src)
+ return dst
+}
+
+// interfacesToString converts a slice of interfaces to a string for display
+func interfacesToString(items []any) string {
+ var s strings.Builder
+ for _, item := range items {
+ switch val := item.(type) {
+ case rune:
+ s.WriteRune(val)
+ case *Attachment:
+ s.WriteString(val.Display)
+ }
+ }
+ return s.String()
+}
+
+// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
+// This allows for proper highlighting even when the cursor is technically at the position
+// after the attachment object in the underlying slice.
+func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
+ if m.row >= len(m.value) {
+ return nil, -1, -1
+ }
+
+ row := m.value[m.row]
+ col := m.col
+
+ if col < 0 || col > len(row) {
+ return nil, -1, -1
+ }
+
+ // Check if the cursor is at the same index as an attachment.
+ if col < len(row) {
+ if att, ok := row[col].(*Attachment); ok {
+ return att, col, col
+ }
+ }
+
+ // Check if the cursor is immediately after an attachment. This is a common
+ // state, for example, after just inserting one.
+ if col > 0 && col <= len(row) {
+ if att, ok := row[col-1].(*Attachment); ok {
+ return att, col - 1, col - 1
+ }
+ }
+
+ return nil, -1, -1
+}
+
+// renderLineWithAttachments renders a line with proper attachment highlighting
+func (m Model) renderLineWithAttachments(
+ items []any,
+ style lipgloss.Style,
+) string {
+ var s strings.Builder
+ currentAttachment, _, _ := m.isAttachmentAtCursor()
+
+ for _, item := range items {
+ switch val := item.(type) {
+ case rune:
+ s.WriteString(style.Render(string(val)))
+ case *Attachment:
+ // Check if this is the attachment the cursor is currently on
+ if currentAttachment != nil && currentAttachment.ID == val.ID {
+ // Cursor is on this attachment, highlight it
+ s.WriteString(m.Styles.SelectedAttachment.Render(val.Display))
+ } else {
+ s.WriteString(m.Styles.Attachment.Render(val.Display))
+ }
+ }
+ }
+ return s.String()
+}
+
+// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune
+func getRuneAt(items []any, index int) rune {
+ if index < 0 || index >= len(items) {
+ return 0
+ }
+ if r, ok := items[index].(rune); ok {
+ return r
+ }
+ return 0
+}
+
+// isSpaceAt checks if the item at index is a space rune
+func isSpaceAt(items []any, index int) bool {
+ r := getRuneAt(items, index)
+ return r != 0 && unicode.IsSpace(r)
+}
+
+// setRuneAt safely sets a rune at a specific position if it's a rune
+func setRuneAt(items []any, index int, r rune) {
+ if index >= 0 && index < len(items) {
+ if _, ok := items[index].(rune); ok {
+ items[index] = r
+ }
+ }
+}
+
// Internal messages for clipboard operations.
type (
pasteMsg string
@@ -70,30 +210,96 @@ type KeyMap struct {
// upon the textarea.
func DefaultKeyMap() KeyMap {
return KeyMap{
- CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")),
- CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")),
- WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")),
- WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")),
- LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")),
- LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")),
- DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")),
- DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")),
- DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")),
- DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")),
- InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")),
- DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")),
- DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")),
- LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")),
- LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")),
- Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")),
- InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")),
- InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")),
-
- CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")),
- LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")),
- UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")),
-
- TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")),
+ CharacterForward: key.NewBinding(
+ key.WithKeys("right", "ctrl+f"),
+ key.WithHelp("right", "character forward"),
+ ),
+ CharacterBackward: key.NewBinding(
+ key.WithKeys("left", "ctrl+b"),
+ key.WithHelp("left", "character backward"),
+ ),
+ WordForward: key.NewBinding(
+ key.WithKeys("alt+right", "alt+f"),
+ key.WithHelp("alt+right", "word forward"),
+ ),
+ WordBackward: key.NewBinding(
+ key.WithKeys("alt+left", "alt+b"),
+ key.WithHelp("alt+left", "word backward"),
+ ),
+ LineNext: key.NewBinding(
+ key.WithKeys("down", "ctrl+n"),
+ key.WithHelp("down", "next line"),
+ ),
+ LinePrevious: key.NewBinding(
+ key.WithKeys("up", "ctrl+p"),
+ key.WithHelp("up", "previous line"),
+ ),
+ DeleteWordBackward: key.NewBinding(
+ key.WithKeys("alt+backspace", "ctrl+w"),
+ key.WithHelp("alt+backspace", "delete word backward"),
+ ),
+ DeleteWordForward: key.NewBinding(
+ key.WithKeys("alt+delete", "alt+d"),
+ key.WithHelp("alt+delete", "delete word forward"),
+ ),
+ DeleteAfterCursor: key.NewBinding(
+ key.WithKeys("ctrl+k"),
+ key.WithHelp("ctrl+k", "delete after cursor"),
+ ),
+ DeleteBeforeCursor: key.NewBinding(
+ key.WithKeys("ctrl+u"),
+ key.WithHelp("ctrl+u", "delete before cursor"),
+ ),
+ InsertNewline: key.NewBinding(
+ key.WithKeys("enter", "ctrl+m"),
+ key.WithHelp("enter", "insert newline"),
+ ),
+ DeleteCharacterBackward: key.NewBinding(
+ key.WithKeys("backspace", "ctrl+h"),
+ key.WithHelp("backspace", "delete character backward"),
+ ),
+ DeleteCharacterForward: key.NewBinding(
+ key.WithKeys("delete", "ctrl+d"),
+ key.WithHelp("delete", "delete character forward"),
+ ),
+ LineStart: key.NewBinding(
+ key.WithKeys("home", "ctrl+a"),
+ key.WithHelp("home", "line start"),
+ ),
+ LineEnd: key.NewBinding(
+ key.WithKeys("end", "ctrl+e"),
+ key.WithHelp("end", "line end"),
+ ),
+ Paste: key.NewBinding(
+ key.WithKeys("ctrl+v"),
+ key.WithHelp("ctrl+v", "paste"),
+ ),
+ InputBegin: key.NewBinding(
+ key.WithKeys("alt+<", "ctrl+home"),
+ key.WithHelp("alt+<", "input begin"),
+ ),
+ InputEnd: key.NewBinding(
+ key.WithKeys("alt+>", "ctrl+end"),
+ key.WithHelp("alt+>", "input end"),
+ ),
+
+ CapitalizeWordForward: key.NewBinding(
+ key.WithKeys("alt+c"),
+ key.WithHelp("alt+c", "capitalize word forward"),
+ ),
+ LowercaseWordForward: key.NewBinding(
+ key.WithKeys("alt+l"),
+ key.WithHelp("alt+l", "lowercase word forward"),
+ ),
+ UppercaseWordForward: key.NewBinding(
+ key.WithKeys("alt+u"),
+ key.WithHelp("alt+u", "uppercase word forward"),
+ ),
+
+ TransposeCharacterBackward: key.NewBinding(
+ key.WithKeys("ctrl+t"),
+ key.WithHelp("ctrl+t", "transpose character backward"),
+ ),
}
}
@@ -160,9 +366,11 @@ type CursorStyle struct {
// states. The appropriate styles will be chosen based on the focus state of
// the textarea.
type Styles struct {
- Focused StyleState
- Blurred StyleState
- Cursor CursorStyle
+ Focused StyleState
+ Blurred StyleState
+ Cursor CursorStyle
+ Attachment lipgloss.Style
+ SelectedAttachment lipgloss.Style
}
// StyleState that will be applied to the text area.
@@ -217,13 +425,22 @@ func (s StyleState) computedText() lipgloss.Style {
// line is the input to the text wrapping function. This is stored in a struct
// so that it can be hashed and memoized.
type line struct {
- runes []rune
- width int
+ content []any // Contains runes and *Attachment
+ width int
}
// Hash returns a hash of the line.
func (w line) Hash() string {
- v := fmt.Sprintf("%s:%d", string(w.runes), w.width)
+ var s strings.Builder
+ for _, item := range w.content {
+ switch v := item.(type) {
+ case rune:
+ s.WriteRune(v)
+ case *Attachment:
+ s.WriteString(v.ID)
+ }
+ }
+ v := fmt.Sprintf("%s:%d", s.String(), w.width)
return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
}
@@ -232,7 +449,7 @@ type Model struct {
Err error
// General settings.
- cache *MemoCache[line, [][]rune]
+ cache *MemoCache[line, [][]any]
// Prompt is printed at the beginning of each line.
//
@@ -295,14 +512,14 @@ type Model struct {
// if there are more lines than the permitted height.
height int
- // Underlying text value.
- value [][]rune
+ // Underlying text value. Contains either rune or *Attachment types.
+ value [][]any
// focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
focus bool
- // Cursor column.
+ // Cursor column (slice index).
col int
// Cursor row.
@@ -328,14 +545,14 @@ func New() Model {
MaxWidth: defaultMaxWidth,
Prompt: lipgloss.ThickBorder().Left + " ",
Styles: styles,
- cache: NewMemoCache[line, [][]rune](maxLines),
+ cache: NewMemoCache[line, [][]any](maxLines),
EndOfBufferCharacter: ' ',
ShowLineNumbers: true,
VirtualCursor: true,
virtualCursor: cur,
KeyMap: DefaultKeyMap(),
- value: make([][]rune, minHeight, maxLines),
+ value: make([][]any, minHeight, maxLines),
focus: false,
col: 0,
row: 0,
@@ -354,25 +571,40 @@ func DefaultStyles(isDark bool) Styles {
var s Styles
s.Focused = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
- CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
- EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle(),
+ Base: lipgloss.NewStyle(),
+ CursorLine: lipgloss.NewStyle().
+ Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
+ CursorLineNumber: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
+ EndOfBuffer: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
+ LineNumber: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
+ Text: lipgloss.NewStyle(),
}
s.Blurred = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- }
+ Base: lipgloss.NewStyle(),
+ CursorLine: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
+ CursorLineNumber: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+ EndOfBuffer: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
+ LineNumber: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+ Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
+ Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
+ Text: lipgloss.NewStyle().
+ Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
+ }
+ s.Attachment = lipgloss.NewStyle().
+ Background(lipgloss.Color("11")).
+ Foreground(lipgloss.Color("0"))
+ s.SelectedAttachment = lipgloss.NewStyle().
+ Background(lipgloss.Color("11")).
+ Foreground(lipgloss.Color("0"))
s.Cursor = CursorStyle{
Color: lipgloss.Color("7"),
Shape: tea.CursorBlock,
@@ -429,6 +661,75 @@ func (m *Model) InsertRune(r rune) {
m.insertRunesFromUserInput([]rune{r})
}
+// InsertAttachment inserts an attachment at the cursor position.
+func (m *Model) InsertAttachment(att *Attachment) {
+ if m.CharLimit > 0 {
+ availSpace := m.CharLimit - m.Length()
+ // If the char limit's been reached, cancel.
+ if availSpace <= 0 {
+ return
+ }
+ }
+
+ // Insert the attachment at the current cursor position
+ m.value[m.row] = append(
+ m.value[m.row][:m.col],
+ append([]any{att}, m.value[m.row][m.col:]...)...)
+ m.col++
+ m.SetCursorColumn(m.col)
+}
+
+// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
+// This preserves attachments outside the replaced range.
+func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
+ if m.row >= len(m.value) || startCol < 0 || endCol < startCol {
+ return
+ }
+
+ // Ensure bounds are within the current row
+ rowLen := len(m.value[m.row])
+ startCol = max(0, min(startCol, rowLen))
+ endCol = max(startCol, min(endCol, rowLen))
+
+ // Create new row content: before + replacement + after
+ before := m.value[m.row][:startCol]
+ after := m.value[m.row][endCol:]
+ replacementRunes := runesToInterfaces([]rune(replacement))
+
+ // Combine the parts
+ newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after))
+ newRow = append(newRow, before...)
+ newRow = append(newRow, replacementRunes...)
+ newRow = append(newRow, after...)
+
+ m.value[m.row] = newRow
+
+ // Position cursor at end of replacement
+ m.col = startCol + len(replacementRunes)
+ m.SetCursorColumn(m.col)
+}
+
+// CurrentRowLength returns the length of the current row.
+func (m *Model) CurrentRowLength() int {
+ if m.row >= len(m.value) {
+ return 0
+ }
+ return len(m.value[m.row])
+}
+
+// GetAttachments returns all attachments in the textarea.
+func (m Model) GetAttachments() []*Attachment {
+ var attachments []*Attachment
+ for _, row := range m.value {
+ for _, item := range row {
+ if att, ok := item.(*Attachment); ok {
+ attachments = append(attachments, att)
+ }
+ }
+ }
+ return attachments
+}
+
// insertRunesFromUserInput inserts runes at the current cursor position.
func (m *Model) insertRunesFromUserInput(runes []rune) {
// Clean up any special characters in the input provided by the
@@ -481,23 +782,22 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
// Save the remainder of the original line at the current
// cursor position.
- tail := make([]rune, len(m.value[m.row][m.col:]))
- copy(tail, m.value[m.row][m.col:])
+ tail := copyInterfaceSlice(m.value[m.row][m.col:])
// Paste the first line at the current cursor position.
- m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...)
+ m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...)
m.col += len(lines[0])
if numExtraLines := len(lines) - 1; numExtraLines > 0 {
// Add the new lines.
// We try to reuse the slice if there's already space.
- var newGrid [][]rune
+ var newGrid [][]any
if cap(m.value) >= len(m.value)+numExtraLines {
// Can reuse the extra space.
newGrid = m.value[:len(m.value)+numExtraLines]
} else {
// No space left; need a new slice.
- newGrid = make([][]rune, len(m.value)+numExtraLines)
+ newGrid = make([][]any, len(m.value)+numExtraLines)
copy(newGrid, m.value[:m.row+1])
}
// Add all the rows that were after the cursor in the original
@@ -507,7 +807,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
// Insert all the new lines in the middle.
for _, l := range lines[1:] {
m.row++
- m.value[m.row] = l
+ m.value[m.row] = runesToInterfaces(l)
m.col = len(l)
}
}
@@ -526,7 +826,14 @@ func (m Model) Value() string {
var v strings.Builder
for _, l := range m.value {
- v.WriteString(string(l))
+ for _, item := range l {
+ switch val := item.(type) {
+ case rune:
+ v.WriteRune(val)
+ case *Attachment:
+ v.WriteString(val.Display)
+ }
+ }
v.WriteByte('\n')
}
@@ -537,7 +844,14 @@ func (m Model) Value() string {
func (m *Model) Length() int {
var l int
for _, row := range m.value {
- l += uniseg.StringWidth(string(row))
+ for _, item := range row {
+ switch val := item.(type) {
+ case rune:
+ l += rw.RuneWidth(val)
+ case *Attachment:
+ l += uniseg.StringWidth(val.Display)
+ }
+ }
}
// We add len(m.value) to include the newline characters.
return l + len(m.value) - 1
@@ -553,6 +867,29 @@ func (m Model) Line() int {
return m.row
}
+// CursorColumn returns the cursor's column position (slice index).
+func (m Model) CursorColumn() int {
+ return m.col
+}
+
+// LastRuneIndex returns the index of the last occurrence of a rune on the current line,
+// searching backwards from the current cursor position.
+// Returns -1 if the rune is not found before the cursor.
+func (m Model) LastRuneIndex(r rune) int {
+ if m.row >= len(m.value) {
+ return -1
+ }
+ // Iterate backwards from just before the cursor position
+ for i := m.col - 1; i >= 0; i-- {
+ if i < len(m.value[m.row]) {
+ if item, ok := m.value[m.row][i].(rune); ok && item == r {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
func (m *Model) Newline() {
if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
return
@@ -561,6 +898,39 @@ func (m *Model) Newline() {
m.splitLine(m.row, m.col)
}
+// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index.
+// This is used to maintain the cursor's horizontal position when moving vertically.
+func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
+ if row < 0 || row >= len(m.value) {
+ return 0
+ }
+
+ offset := 0
+ // Find the slice index that corresponds to the visual offset.
+ for i, item := range m.value[row] {
+ var itemWidth int
+ switch v := item.(type) {
+ case rune:
+ itemWidth = rw.RuneWidth(v)
+ case *Attachment:
+ itemWidth = uniseg.StringWidth(v.Display)
+ }
+
+ // If the target offset falls within the current item, this is our index.
+ if offset+itemWidth > charOffset {
+ // Decide whether to stick with the previous index or move to the current
+ // one based on which is closer to the target offset.
+ if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
+ return i + 1
+ }
+ return i
+ }
+ offset += itemWidth
+ }
+
+ return len(m.value[row])
+}
+
// CursorDown moves the cursor down by one line.
// Returns whether or not the cursor blink should be reset.
func (m *Model) CursorDown() {
@@ -569,31 +939,15 @@ func (m *Model) CursorDown() {
m.lastCharOffset = charOffset
if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
+ // Move to the next model line
m.row++
- m.col = 0
- } else {
- // Move the cursor to the start of the next line so that we can get
- // the line information. We need to add 2 columns to account for the
- // trailing space wrapping.
- const trailingSpace = 2
- m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1)
- }
-
- nli := m.LineInfo()
- m.col = nli.StartColumn
-
- if nli.Width <= 0 {
- return
- }
-
- offset := 0
- for offset < charOffset {
- if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 {
- break
- }
- offset += rw.RuneWidth(m.value[m.row][m.col])
- m.col++
+ m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
+ } else if li.RowOffset+1 < li.Height {
+ // Move to the next wrapped line within the same model line
+ startOfNextWrappedLine := li.StartColumn + li.Width
+ m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset)
}
+ m.SetCursorColumn(m.col)
}
// CursorUp moves the cursor up by one line.
@@ -603,32 +957,24 @@ func (m *Model) CursorUp() {
m.lastCharOffset = charOffset
if li.RowOffset <= 0 && m.row > 0 {
+ // Move to the previous model line
m.row--
- m.col = len(m.value[m.row])
- } else {
- // Move the cursor to the end of the previous line.
- // This can be done by moving the cursor to the start of the line and
- // then subtracting 2 to account for the trailing space we keep on
- // soft-wrapped lines.
- const trailingSpace = 2
- m.col = li.StartColumn - trailingSpace
- }
-
- nli := m.LineInfo()
- m.col = nli.StartColumn
-
- if nli.Width <= 0 {
- return
- }
-
- offset := 0
- for offset < charOffset {
- if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 {
- break
+ m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
+ } else if li.RowOffset > 0 {
+ // Move to the previous wrapped line within the same model line
+ // To do this, we need to find the start of the previous wrapped line.
+ prevLineInfo := m.LineInfo()
+ // prevLineStart := 0
+ if prevLineInfo.RowOffset > 0 {
+ // This is complex, so we'll approximate by moving to the start of the current wrapped line
+ // and then letting characterLeft handle it. A more precise calculation would
+ // require re-wrapping to find the previous line's start.
+ // For now, a simpler approach:
+ m.col = li.StartColumn - 1
}
- offset += rw.RuneWidth(m.value[m.row][m.col])
- m.col++
+ m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
}
+ m.SetCursorColumn(m.col)
}
// SetCursorColumn moves the cursor to the given position. If the position is
@@ -680,7 +1026,7 @@ func (m *Model) Blur() {
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
- m.value = make([][]rune, minHeight, maxLines)
+ m.value = make([][]any, minHeight, maxLines)
m.col = 0
m.row = 0
m.SetCursorColumn(0)
@@ -741,7 +1087,7 @@ func (m *Model) deleteWordLeft() {
oldCol := m.col //nolint:ifshort
m.SetCursorColumn(m.col - 1)
- for unicode.IsSpace(m.value[m.row][m.col]) {
+ for isSpaceAt(m.value[m.row], m.col) {
if m.col <= 0 {
break
}
@@ -750,7 +1096,7 @@ func (m *Model) deleteWordLeft() {
}
for m.col > 0 {
- if !unicode.IsSpace(m.value[m.row][m.col]) {
+ if !isSpaceAt(m.value[m.row], m.col) {
m.SetCursorColumn(m.col - 1)
} else {
if m.col > 0 {
@@ -776,13 +1122,13 @@ func (m *Model) deleteWordRight() {
oldCol := m.col
- for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) {
+ for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) {
// ignore series of whitespace after cursor
m.SetCursorColumn(m.col + 1)
}
for m.col < len(m.value[m.row]) {
- if !unicode.IsSpace(m.value[m.row][m.col]) {
+ if !isSpaceAt(m.value[m.row], m.col) {
m.SetCursorColumn(m.col + 1)
} else {
break
@@ -832,13 +1178,13 @@ func (m *Model) characterLeft(insideLine bool) {
func (m *Model) wordLeft() {
for {
m.characterLeft(true /* insideLine */)
- if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) {
+ if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) {
break
}
}
for m.col > 0 {
- if unicode.IsSpace(m.value[m.row][m.col-1]) {
+ if isSpaceAt(m.value[m.row], m.col-1) {
break
}
m.SetCursorColumn(m.col - 1)
@@ -854,7 +1200,7 @@ func (m *Model) wordRight() {
func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
// Skip spaces forward.
- for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) {
+ for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) {
if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
// End of text.
break
@@ -864,7 +1210,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
charIdx := 0
for m.col < len(m.value[m.row]) {
- if unicode.IsSpace(m.value[m.row][m.col]) {
+ if isSpaceAt(m.value[m.row], m.col) {
break
}
fn(charIdx, m.col)
@@ -876,14 +1222,18 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
// uppercaseRight changes the word to the right to uppercase.
func (m *Model) uppercaseRight() {
m.doWordRight(func(_ int, i int) {
- m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i])
+ if r, ok := m.value[m.row][i].(rune); ok {
+ m.value[m.row][i] = unicode.ToUpper(r)
+ }
})
}
// lowercaseRight changes the word to the right to lowercase.
func (m *Model) lowercaseRight() {
m.doWordRight(func(_ int, i int) {
- m.value[m.row][i] = unicode.ToLower(m.value[m.row][i])
+ if r, ok := m.value[m.row][i].(rune); ok {
+ m.value[m.row][i] = unicode.ToLower(r)
+ }
})
}
@@ -891,7 +1241,9 @@ func (m *Model) lowercaseRight() {
func (m *Model) capitalizeRight() {
m.doWordRight(func(charIdx int, i int) {
if charIdx == 0 {
- m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i])
+ if r, ok := m.value[m.row][i].(rune); ok {
+ m.value[m.row][i] = unicode.ToTitle(r)
+ }
}
})
}
@@ -905,34 +1257,39 @@ func (m Model) LineInfo() LineInfo {
// m.col and counting the number of runes that we need to skip.
var counter int
for i, line := range grid {
- // We've found the line that we are on
- if counter+len(line) == m.col && i+1 < len(grid) {
- // We wrap around to the next line if we are at the end of the
- // previous line so that we can be at the very beginning of the row
- return LineInfo{
- CharOffset: 0,
- ColumnOffset: 0,
- Height: len(grid),
- RowOffset: i + 1,
- StartColumn: m.col,
- Width: len(grid[i+1]),
- CharWidth: uniseg.StringWidth(string(line)),
+ start := counter
+ end := counter + len(line)
+
+ if m.col >= start && m.col <= end {
+ // This is the wrapped line the cursor is on.
+
+ // Special case: if the cursor is at the end of a wrapped line,
+ // and there's another wrapped line after it, the cursor should
+ // be considered at the beginning of the next line.
+ if m.col == end && i < len(grid)-1 {
+ nextLine := grid[i+1]
+ return LineInfo{
+ CharOffset: 0,
+ ColumnOffset: 0,
+ Height: len(grid),
+ RowOffset: i + 1,
+ StartColumn: end,
+ Width: len(nextLine),
+ CharWidth: uniseg.StringWidth(interfacesToString(nextLine)),
+ }
}
- }
- if counter+len(line) >= m.col {
return LineInfo{
- CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])),
- ColumnOffset: m.col - counter,
+ CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])),
+ ColumnOffset: m.col - start,
Height: len(grid),
RowOffset: i,
- StartColumn: counter,
+ StartColumn: start,
Width: len(line),
- CharWidth: uniseg.StringWidth(string(line)),
+ CharWidth: uniseg.StringWidth(interfacesToString(line)),
}
}
-
- counter += len(line)
+ counter = end
}
return LineInfo{}
}
@@ -1060,12 +1417,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
+ if m.row >= len(m.value) {
+ m.value = append(m.value, make([]any, 0))
+ }
if m.value[m.row] == nil {
- m.value[m.row] = make([]rune, 0)
+ m.value[m.row] = make([]any, 0)
}
if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
- m.cache = NewMemoCache[line, [][]rune](m.MaxHeight)
+ m.cache = NewMemoCache[line, [][]any](m.MaxHeight)
}
switch msg := msg.(type) {
@@ -1093,11 +1453,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.mergeLineAbove(m.row)
break
}
- if len(m.value[m.row]) > 0 {
- m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
- if m.col > 0 {
- m.SetCursorColumn(m.col - 1)
- }
+ if len(m.value[m.row]) > 0 && m.col > 0 {
+ m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col)
+ m.SetCursorColumn(m.col - 1)
}
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
@@ -1154,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.transposeLeft()
default:
- m.insertRunesFromUserInput([]rune(msg.Text))
+ m.insertRunesFromUserInput([]rune{msg.Code})
}
case pasteMsg:
@@ -1226,7 +1584,8 @@ func (m Model) View() string {
widestLineNumber = lnw
}
- strwidth := uniseg.StringWidth(string(wrappedLine))
+ wrappedLineStr := interfacesToString(wrappedLine)
+ strwidth := uniseg.StringWidth(wrappedLineStr)
padding := m.width - strwidth
// If the trailing space causes the line to be wider than the
// width, we should not draw it to the screen since it will result
@@ -1236,22 +1595,46 @@ func (m Model) View() string {
// The character causing the line to be wider than the width is
// guaranteed to be a space since any other character would
// have been wrapped.
- wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " "))
+ wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
padding -= m.width - strwidth
}
+
if m.row == l && lineInfo.RowOffset == wl {
- s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset])))
+ // Render the part of the line before the cursor
+ s.WriteString(
+ m.renderLineWithAttachments(
+ wrappedLine[:lineInfo.ColumnOffset],
+ style,
+ ),
+ )
+
if m.col >= len(line) && lineInfo.CharOffset >= m.width {
m.virtualCursor.SetChar(" ")
s.WriteString(m.virtualCursor.View())
+ } else if lineInfo.ColumnOffset < len(wrappedLine) {
+ // Render the item under the cursor
+ item := wrappedLine[lineInfo.ColumnOffset]
+ if att, ok := item.(*Attachment); ok {
+ // Item at cursor is an attachment. Render it with the selection style.
+ // This becomes the "cursor" visually.
+ s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
+ } else {
+ // Item at cursor is a rune. Render it with the virtual cursor.
+ m.virtualCursor.SetChar(string(item.(rune)))
+ s.WriteString(style.Render(m.virtualCursor.View()))
+ }
+
+ // Render the part of the line after the cursor
+ s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style))
} else {
- m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset]))
+ // Cursor is at the end of the line
+ m.virtualCursor.SetChar(" ")
s.WriteString(style.Render(m.virtualCursor.View()))
- s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:])))
}
} else {
- s.WriteString(style.Render(string(wrappedLine)))
+ s.WriteString(m.renderLineWithAttachments(wrappedLine, style))
}
+
s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
s.WriteRune('\n')
newLines++
@@ -1443,12 +1826,12 @@ func (m Model) Cursor() *tea.Cursor {
return c
}
-func (m Model) memoizedWrap(runes []rune, width int) [][]rune {
- input := line{runes: runes, width: width}
+func (m Model) memoizedWrap(content []any, width int) [][]any {
+ input := line{content: content, width: width}
if v, ok := m.cache.Get(input); ok {
return v
}
- v := wrap(runes, width)
+ v := wrapInterfaces(content, width)
m.cache.Set(input, v)
return v
}
@@ -1514,8 +1897,7 @@ func (m *Model) splitLine(row, col int) {
// the cursor, take the content after the cursor and make it the content of
// the line underneath, and shift the remaining lines down by one
head, tailSrc := m.value[row][:col], m.value[row][col:]
- tail := make([]rune, len(tailSrc))
- copy(tail, tailSrc)
+ tail := copyInterfaceSlice(tailSrc)
m.value = append(m.value[:row+1], m.value[row:]...)
@@ -1535,66 +1917,84 @@ func Paste() tea.Msg {
return pasteMsg(str)
}
-func wrap(runes []rune, width int) [][]rune {
+func wrapInterfaces(content []any, width int) [][]any {
+ if width <= 0 {
+ return [][]any{content}
+ }
+
var (
- lines = [][]rune{{}}
- word = []rune{}
- row int
- spaces int
+ lines = [][]any{{}}
+ word = []any{}
+ wordW int
+ lineW int
+ spaceW int
+ inSpaces bool
)
- // Word wrap the runes
- for _, r := range runes {
- if unicode.IsSpace(r) {
- spaces++
- } else {
- word = append(word, r)
+ for _, item := range content {
+ itemW := 0
+ isSpace := false
+
+ if r, ok := item.(rune); ok {
+ if unicode.IsSpace(r) {
+ isSpace = true
+ }
+ itemW = rw.RuneWidth(r)
+ } else if att, ok := item.(*Attachment); ok {
+ itemW = uniseg.StringWidth(att.Display)
}
- if spaces > 0 { //nolint:nestif
- if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width {
- row++
- lines = append(lines, []rune{})
- lines[row] = append(lines[row], word...)
- lines[row] = append(lines[row], repeatSpaces(spaces)...)
- spaces = 0
- word = nil
- } else {
- lines[row] = append(lines[row], word...)
- lines[row] = append(lines[row], repeatSpaces(spaces)...)
- spaces = 0
+ if isSpace {
+ if !inSpaces {
+ // End of a word
+ if lineW > 0 && lineW+wordW > width {
+ lines = append(lines, word)
+ lineW = wordW
+ } else {
+ lines[len(lines)-1] = append(lines[len(lines)-1], word...)
+ lineW += wordW
+ }
word = nil
+ wordW = 0
}
+ inSpaces = true
+ spaceW += itemW
} else {
- // If the last character is a double-width rune, then we may not be able to add it to this line
- // as it might cause us to go past the width.
- lastCharLen := rw.RuneWidth(word[len(word)-1])
- if uniseg.StringWidth(string(word))+lastCharLen > width {
- // If the current line has any content, let's move to the next
- // line because the current word fills up the entire line.
- if len(lines[row]) > 0 {
- row++
- lines = append(lines, []rune{})
+ if inSpaces {
+ // End of spaces
+ if lineW > 0 && lineW+spaceW > width {
+ lines = append(lines, []any{})
+ lineW = 0
+ } else {
+ lineW += spaceW
}
- lines[row] = append(lines[row], word...)
- word = nil
+ // Add spaces to current line
+ for i := 0; i < spaceW; i++ {
+ lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
+ }
+ spaceW = 0
}
+ inSpaces = false
+ word = append(word, item)
+ wordW += itemW
}
}
- if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width {
- lines = append(lines, []rune{})
- lines[row+1] = append(lines[row+1], word...)
- // We add an extra space at the end of the line to account for the
- // trailing space at the end of the previous soft-wrapped lines so that
- // behaviour when navigating is consistent and so that we don't need to
- // continually add edges to handle the last line of the wrapped input.
- spaces++
- lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...)
- } else {
- lines[row] = append(lines[row], word...)
- spaces++
- lines[row] = append(lines[row], repeatSpaces(spaces)...)
+ // Handle any remaining word/spaces
+ if wordW > 0 {
+ if lineW > 0 && lineW+wordW > width {
+ lines = append(lines, word)
+ } else {
+ lines[len(lines)-1] = append(lines[len(lines)-1], word...)
+ }
+ }
+ if spaceW > 0 {
+ if lineW > 0 && lineW+spaceW > width {
+ lines = append(lines, []any{})
+ }
+ for i := 0; i < spaceW; i++ {
+ lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
+ }
}
return lines
diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go
deleted file mode 100644
index a03346eb7..000000000
--- a/packages/tui/internal/layout/flex_example_test.go
+++ /dev/null
@@ -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
-}
diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go
deleted file mode 100644
index cad38dc8f..000000000
--- a/packages/tui/internal/layout/flex_test.go
+++ /dev/null
@@ -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)
- }
- })
- }
-}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 29235229d..150a1b26b 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/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),