editor.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. package chat
  2. import (
  3. "encoding/base64"
  4. "fmt"
  5. "log/slog"
  6. "net/url"
  7. "os"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "github.com/charmbracelet/bubbles/v2/spinner"
  12. tea "github.com/charmbracelet/bubbletea/v2"
  13. "github.com/charmbracelet/lipgloss/v2"
  14. "github.com/google/uuid"
  15. "github.com/sst/opencode-sdk-go"
  16. "github.com/sst/opencode/internal/app"
  17. "github.com/sst/opencode/internal/attachment"
  18. "github.com/sst/opencode/internal/clipboard"
  19. "github.com/sst/opencode/internal/commands"
  20. "github.com/sst/opencode/internal/components/dialog"
  21. "github.com/sst/opencode/internal/components/textarea"
  22. "github.com/sst/opencode/internal/styles"
  23. "github.com/sst/opencode/internal/theme"
  24. "github.com/sst/opencode/internal/util"
  25. )
  26. type EditorComponent interface {
  27. tea.Model
  28. tea.ViewModel
  29. Content() string
  30. Lines() int
  31. Value() string
  32. Length() int
  33. Focused() bool
  34. Focus() (tea.Model, tea.Cmd)
  35. Blur()
  36. Submit() (tea.Model, tea.Cmd)
  37. Clear() (tea.Model, tea.Cmd)
  38. Paste() (tea.Model, tea.Cmd)
  39. Newline() (tea.Model, tea.Cmd)
  40. SetValue(value string)
  41. SetValueWithAttachments(value string)
  42. SetInterruptKeyInDebounce(inDebounce bool)
  43. SetExitKeyInDebounce(inDebounce bool)
  44. RestoreFromHistory(index int)
  45. }
  46. type editorComponent struct {
  47. app *app.App
  48. width int
  49. textarea textarea.Model
  50. spinner spinner.Model
  51. interruptKeyInDebounce bool
  52. exitKeyInDebounce bool
  53. historyIndex int // -1 means current (not in history)
  54. currentText string // Store current text when navigating history
  55. pasteCounter int
  56. }
  57. func (m *editorComponent) Init() tea.Cmd {
  58. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  59. }
  60. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  61. var cmds []tea.Cmd
  62. var cmd tea.Cmd
  63. switch msg := msg.(type) {
  64. case tea.WindowSizeMsg:
  65. m.width = msg.Width - 4
  66. return m, nil
  67. case spinner.TickMsg:
  68. m.spinner, cmd = m.spinner.Update(msg)
  69. return m, cmd
  70. case tea.KeyPressMsg:
  71. // Handle up/down arrows and ctrl+p/ctrl+n for history navigation
  72. switch msg.String() {
  73. case "up", "ctrl+p":
  74. // Only navigate history if cursor is at the first line and column (for arrow keys)
  75. // or allow ctrl+p from anywhere
  76. if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
  77. if m.historyIndex == -1 {
  78. // Save current text before entering history
  79. m.currentText = m.textarea.Value()
  80. m.textarea.MoveToBegin()
  81. }
  82. // Move up in history (older messages)
  83. if m.historyIndex < len(m.app.State.MessageHistory)-1 {
  84. m.historyIndex++
  85. m.RestoreFromHistory(m.historyIndex)
  86. m.textarea.MoveToBegin()
  87. }
  88. return m, nil
  89. }
  90. case "down", "ctrl+n":
  91. // Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
  92. // or allow ctrl+n from anywhere if we're in history navigation
  93. if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
  94. // Move down in history (newer messages)
  95. m.historyIndex--
  96. if m.historyIndex == -1 {
  97. // Restore current text
  98. m.textarea.Reset()
  99. m.textarea.SetValue(m.currentText)
  100. m.currentText = ""
  101. } else {
  102. m.RestoreFromHistory(m.historyIndex)
  103. m.textarea.MoveToEnd()
  104. }
  105. return m, nil
  106. } else if m.historyIndex > -1 && msg.String() == "down" {
  107. m.textarea.MoveToEnd()
  108. return m, nil
  109. }
  110. }
  111. // Reset history navigation on any other input
  112. if m.historyIndex != -1 {
  113. m.historyIndex = -1
  114. m.currentText = ""
  115. }
  116. // Maximize editor responsiveness for printable characters
  117. if msg.Text != "" {
  118. m.textarea, cmd = m.textarea.Update(msg)
  119. cmds = append(cmds, cmd)
  120. return m, tea.Batch(cmds...)
  121. }
  122. case tea.PasteMsg:
  123. text := string(msg)
  124. if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
  125. statPath := filePath
  126. if !filepath.IsAbs(filePath) {
  127. statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  128. }
  129. if _, err := os.Stat(statPath); err == nil {
  130. attachment := m.createAttachmentFromPath(filePath)
  131. if attachment != nil {
  132. m.textarea.InsertAttachment(attachment)
  133. m.textarea.InsertString(" ")
  134. return m, nil
  135. }
  136. }
  137. }
  138. text = strings.ReplaceAll(text, "\\", "")
  139. text, err := strconv.Unquote(`"` + text + `"`)
  140. if err != nil {
  141. slog.Error("Failed to unquote text", "error", err)
  142. text := string(msg)
  143. if m.shouldSummarizePastedText(text) {
  144. m.handleLongPaste(text)
  145. } else {
  146. m.textarea.InsertRunesFromUserInput([]rune(msg))
  147. }
  148. return m, nil
  149. }
  150. if _, err := os.Stat(text); err != nil {
  151. slog.Error("Failed to paste file", "error", err)
  152. text := string(msg)
  153. if m.shouldSummarizePastedText(text) {
  154. m.handleLongPaste(text)
  155. } else {
  156. m.textarea.InsertRunesFromUserInput([]rune(msg))
  157. }
  158. return m, nil
  159. }
  160. filePath := text
  161. attachment := m.createAttachmentFromFile(filePath)
  162. if attachment == nil {
  163. if m.shouldSummarizePastedText(text) {
  164. m.handleLongPaste(text)
  165. } else {
  166. m.textarea.InsertRunesFromUserInput([]rune(msg))
  167. }
  168. return m, nil
  169. }
  170. m.textarea.InsertAttachment(attachment)
  171. m.textarea.InsertString(" ")
  172. case tea.ClipboardMsg:
  173. text := string(msg)
  174. // Check if the pasted text is long and should be summarized
  175. if m.shouldSummarizePastedText(text) {
  176. m.handleLongPaste(text)
  177. } else {
  178. m.textarea.InsertRunesFromUserInput([]rune(text))
  179. }
  180. case dialog.ThemeSelectedMsg:
  181. m.textarea = updateTextareaStyles(m.textarea)
  182. m.spinner = createSpinner()
  183. return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
  184. case dialog.CompletionSelectedMsg:
  185. switch msg.Item.ProviderID {
  186. case "commands":
  187. commandName := strings.TrimPrefix(msg.Item.Value, "/")
  188. updated, cmd := m.Clear()
  189. m = updated.(*editorComponent)
  190. cmds = append(cmds, cmd)
  191. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  192. return m, tea.Batch(cmds...)
  193. case "files":
  194. atIndex := m.textarea.LastRuneIndex('@')
  195. if atIndex == -1 {
  196. // Should not happen, but as a fallback, just insert.
  197. m.textarea.InsertString(msg.Item.Value + " ")
  198. return m, nil
  199. }
  200. // The range to replace is from the '@' up to the current cursor position.
  201. // Replace the search term (e.g., "@search") with an empty string first.
  202. cursorCol := m.textarea.CursorColumn()
  203. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  204. // Now, insert the attachment at the position where the '@' was.
  205. // The cursor is now at `atIndex` after the replacement.
  206. filePath := msg.Item.Value
  207. attachment := m.createAttachmentFromPath(filePath)
  208. m.textarea.InsertAttachment(attachment)
  209. m.textarea.InsertString(" ")
  210. return m, nil
  211. case "symbols":
  212. atIndex := m.textarea.LastRuneIndex('@')
  213. if atIndex == -1 {
  214. // Should not happen, but as a fallback, just insert.
  215. m.textarea.InsertString(msg.Item.Value + " ")
  216. return m, nil
  217. }
  218. cursorCol := m.textarea.CursorColumn()
  219. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  220. symbol := msg.Item.RawData.(opencode.Symbol)
  221. parts := strings.Split(symbol.Name, ".")
  222. lastPart := parts[len(parts)-1]
  223. attachment := &attachment.Attachment{
  224. ID: uuid.NewString(),
  225. Type: "symbol",
  226. Display: "@" + lastPart,
  227. URL: msg.Item.Value,
  228. Filename: lastPart,
  229. MediaType: "text/plain",
  230. Source: &attachment.SymbolSource{
  231. Path: symbol.Location.Uri,
  232. Name: symbol.Name,
  233. Kind: int(symbol.Kind),
  234. Range: attachment.SymbolRange{
  235. Start: attachment.Position{
  236. Line: int(symbol.Location.Range.Start.Line),
  237. Char: int(symbol.Location.Range.Start.Character),
  238. },
  239. End: attachment.Position{
  240. Line: int(symbol.Location.Range.End.Line),
  241. Char: int(symbol.Location.Range.End.Character),
  242. },
  243. },
  244. },
  245. }
  246. m.textarea.InsertAttachment(attachment)
  247. m.textarea.InsertString(" ")
  248. return m, nil
  249. default:
  250. slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
  251. return m, nil
  252. }
  253. }
  254. m.spinner, cmd = m.spinner.Update(msg)
  255. cmds = append(cmds, cmd)
  256. m.textarea, cmd = m.textarea.Update(msg)
  257. cmds = append(cmds, cmd)
  258. return m, tea.Batch(cmds...)
  259. }
  260. func (m *editorComponent) Content() string {
  261. width := m.width
  262. if m.app.Session.ID == "" {
  263. width = min(width, 80)
  264. }
  265. t := theme.CurrentTheme()
  266. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  267. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  268. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  269. Padding(0, 0, 0, 1).
  270. Bold(true)
  271. prompt := promptStyle.Render(">")
  272. m.textarea.SetWidth(width - 6)
  273. textarea := lipgloss.JoinHorizontal(
  274. lipgloss.Top,
  275. prompt,
  276. m.textarea.View(),
  277. )
  278. borderForeground := t.Border()
  279. if m.app.IsLeaderSequence {
  280. borderForeground = t.Accent()
  281. }
  282. textarea = styles.NewStyle().
  283. Background(t.BackgroundElement()).
  284. Width(width).
  285. PaddingTop(1).
  286. PaddingBottom(1).
  287. BorderStyle(lipgloss.ThickBorder()).
  288. BorderForeground(borderForeground).
  289. BorderBackground(t.Background()).
  290. BorderLeft(true).
  291. BorderRight(true).
  292. Render(textarea)
  293. hint := base(m.getSubmitKeyText()) + muted(" send ")
  294. if m.exitKeyInDebounce {
  295. keyText := m.getExitKeyText()
  296. hint = base(keyText+" again") + muted(" to exit")
  297. } else if m.app.IsBusy() {
  298. keyText := m.getInterruptKeyText()
  299. if m.interruptKeyInDebounce {
  300. hint = muted(
  301. "working",
  302. ) + m.spinner.View() + muted(
  303. " ",
  304. ) + base(
  305. keyText+" again",
  306. ) + muted(
  307. " interrupt",
  308. )
  309. } else {
  310. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  311. }
  312. }
  313. model := ""
  314. if m.app.Model != nil {
  315. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  316. }
  317. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  318. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  319. info := hint + spacer + model
  320. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  321. content := strings.Join([]string{"", textarea, info}, "\n")
  322. return content
  323. }
  324. func (m *editorComponent) View() string {
  325. width := m.width
  326. if m.app.Session.ID == "" {
  327. width = min(width, 80)
  328. }
  329. if m.Lines() > 1 {
  330. return lipgloss.Place(
  331. width,
  332. 5,
  333. lipgloss.Center,
  334. lipgloss.Center,
  335. "",
  336. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  337. )
  338. }
  339. return m.Content()
  340. }
  341. func (m *editorComponent) Focused() bool {
  342. return m.textarea.Focused()
  343. }
  344. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  345. return m, m.textarea.Focus()
  346. }
  347. func (m *editorComponent) Blur() {
  348. m.textarea.Blur()
  349. }
  350. func (m *editorComponent) Lines() int {
  351. return m.textarea.LineCount()
  352. }
  353. func (m *editorComponent) Value() string {
  354. return m.textarea.Value()
  355. }
  356. func (m *editorComponent) Length() int {
  357. return m.textarea.Length()
  358. }
  359. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  360. value := strings.TrimSpace(m.Value())
  361. if value == "" {
  362. return m, nil
  363. }
  364. switch value {
  365. case "exit", "quit", "q", ":q":
  366. return m, tea.Quit
  367. }
  368. if len(value) > 0 && value[len(value)-1] == '\\' {
  369. // If the last character is a backslash, remove it and add a newline
  370. backslashCol := m.textarea.CurrentRowLength() - 1
  371. m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
  372. m.textarea.InsertString("\n")
  373. return m, nil
  374. }
  375. var cmds []tea.Cmd
  376. attachments := m.textarea.GetAttachments()
  377. prompt := app.Prompt{Text: value, Attachments: attachments}
  378. m.app.State.AddPromptToHistory(prompt)
  379. cmds = append(cmds, m.app.SaveState())
  380. updated, cmd := m.Clear()
  381. m = updated.(*editorComponent)
  382. cmds = append(cmds, cmd)
  383. cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
  384. return m, tea.Batch(cmds...)
  385. }
  386. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  387. m.textarea.Reset()
  388. m.historyIndex = -1
  389. m.currentText = ""
  390. m.pasteCounter = 0
  391. return m, nil
  392. }
  393. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  394. imageBytes := clipboard.Read(clipboard.FmtImage)
  395. if imageBytes != nil {
  396. attachmentCount := len(m.textarea.GetAttachments())
  397. attachmentIndex := attachmentCount + 1
  398. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  399. attachment := &attachment.Attachment{
  400. ID: uuid.NewString(),
  401. Type: "file",
  402. MediaType: "image/png",
  403. Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
  404. Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
  405. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  406. Source: &attachment.FileSource{
  407. Path: fmt.Sprintf("image-%d.png", attachmentIndex),
  408. Mime: "image/png",
  409. Data: imageBytes,
  410. },
  411. }
  412. m.textarea.InsertAttachment(attachment)
  413. m.textarea.InsertString(" ")
  414. return m, nil
  415. }
  416. textBytes := clipboard.Read(clipboard.FmtText)
  417. if textBytes != nil {
  418. text := string(textBytes)
  419. // Check if the pasted text is long and should be summarized
  420. if m.shouldSummarizePastedText(text) {
  421. m.handleLongPaste(text)
  422. } else {
  423. m.textarea.InsertRunesFromUserInput([]rune(text))
  424. }
  425. return m, nil
  426. }
  427. // fallback to reading the clipboard using OSC52
  428. return m, tea.ReadClipboard
  429. }
  430. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  431. m.textarea.Newline()
  432. return m, nil
  433. }
  434. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  435. m.interruptKeyInDebounce = inDebounce
  436. }
  437. func (m *editorComponent) SetValue(value string) {
  438. m.textarea.SetValue(value)
  439. }
  440. func (m *editorComponent) SetValueWithAttachments(value string) {
  441. m.textarea.Reset()
  442. i := 0
  443. for i < len(value) {
  444. // Check if filepath and add attachment
  445. if value[i] == '@' {
  446. start := i + 1
  447. end := start
  448. for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
  449. end++
  450. }
  451. if end > start {
  452. filePath := value[start:end]
  453. slog.Debug("test", "filePath", filePath)
  454. if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
  455. slog.Debug("test", "found", true)
  456. attachment := m.createAttachmentFromFile(filePath)
  457. if attachment != nil {
  458. m.textarea.InsertAttachment(attachment)
  459. i = end
  460. continue
  461. }
  462. }
  463. }
  464. }
  465. // Not a valid file path, insert the character normally
  466. m.textarea.InsertRune(rune(value[i]))
  467. i++
  468. }
  469. }
  470. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  471. m.exitKeyInDebounce = inDebounce
  472. }
  473. func (m *editorComponent) getInterruptKeyText() string {
  474. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  475. }
  476. func (m *editorComponent) getSubmitKeyText() string {
  477. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  478. }
  479. func (m *editorComponent) getExitKeyText() string {
  480. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  481. }
  482. // shouldSummarizePastedText determines if pasted text should be summarized
  483. func (m *editorComponent) shouldSummarizePastedText(text string) bool {
  484. lines := strings.Split(text, "\n")
  485. lineCount := len(lines)
  486. charCount := len(text)
  487. // Consider text long if it has more than 3 lines or more than 150 characters
  488. return lineCount > 3 || charCount > 150
  489. }
  490. // handleLongPaste handles long pasted text by creating a summary attachment
  491. func (m *editorComponent) handleLongPaste(text string) {
  492. lines := strings.Split(text, "\n")
  493. lineCount := len(lines)
  494. // Increment paste counter
  495. m.pasteCounter++
  496. // Create attachment with full text as base64 encoded data
  497. fileBytes := []byte(text)
  498. base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
  499. url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
  500. fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
  501. displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
  502. attachment := &attachment.Attachment{
  503. ID: uuid.NewString(),
  504. Type: "text",
  505. MediaType: "text/plain",
  506. Display: displayText,
  507. URL: url,
  508. Filename: fileName,
  509. Source: &attachment.TextSource{
  510. Value: text,
  511. },
  512. }
  513. m.textarea.InsertAttachment(attachment)
  514. m.textarea.InsertString(" ")
  515. }
  516. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  517. t := theme.CurrentTheme()
  518. bgColor := t.BackgroundElement()
  519. textColor := t.Text()
  520. textMutedColor := t.TextMuted()
  521. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  522. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  523. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  524. Foreground(textMutedColor).
  525. Background(bgColor).
  526. Lipgloss()
  527. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  528. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  529. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  530. ta.Styles.Focused.Placeholder = styles.NewStyle().
  531. Foreground(textMutedColor).
  532. Background(bgColor).
  533. Lipgloss()
  534. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  535. ta.Styles.Attachment = styles.NewStyle().
  536. Foreground(t.Secondary()).
  537. Background(bgColor).
  538. Lipgloss()
  539. ta.Styles.SelectedAttachment = styles.NewStyle().
  540. Foreground(t.Text()).
  541. Background(t.Secondary()).
  542. Lipgloss()
  543. ta.Styles.Cursor.Color = t.Primary()
  544. return ta
  545. }
  546. func createSpinner() spinner.Model {
  547. t := theme.CurrentTheme()
  548. return spinner.New(
  549. spinner.WithSpinner(spinner.Ellipsis),
  550. spinner.WithStyle(
  551. styles.NewStyle().
  552. Background(t.Background()).
  553. Foreground(t.TextMuted()).
  554. Width(3).
  555. Lipgloss(),
  556. ),
  557. )
  558. }
  559. func NewEditorComponent(app *app.App) EditorComponent {
  560. s := createSpinner()
  561. ta := textarea.New()
  562. ta.Prompt = " "
  563. ta.ShowLineNumbers = false
  564. ta.CharLimit = -1
  565. ta = updateTextareaStyles(ta)
  566. m := &editorComponent{
  567. app: app,
  568. textarea: ta,
  569. spinner: s,
  570. interruptKeyInDebounce: false,
  571. historyIndex: -1,
  572. pasteCounter: 0,
  573. }
  574. return m
  575. }
  576. // RestoreFromHistory restores a message from history at the given index
  577. func (m *editorComponent) RestoreFromHistory(index int) {
  578. if index < 0 || index >= len(m.app.State.MessageHistory) {
  579. return
  580. }
  581. entry := m.app.State.MessageHistory[index]
  582. m.textarea.Reset()
  583. m.textarea.SetValue(entry.Text)
  584. // Sort attachments by start index in reverse order (process from end to beginning)
  585. // This prevents index shifting issues
  586. attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
  587. copy(attachmentsCopy, entry.Attachments)
  588. for i := 0; i < len(attachmentsCopy)-1; i++ {
  589. for j := i + 1; j < len(attachmentsCopy); j++ {
  590. if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
  591. attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
  592. }
  593. }
  594. }
  595. for _, att := range attachmentsCopy {
  596. m.textarea.SetCursorColumn(att.StartIndex)
  597. m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
  598. m.textarea.InsertAttachment(att)
  599. }
  600. }
  601. func getMediaTypeFromExtension(ext string) string {
  602. switch strings.ToLower(ext) {
  603. case ".jpg":
  604. return "image/jpeg"
  605. case ".png", ".jpeg", ".gif", ".webp":
  606. return "image/" + ext[1:]
  607. case ".pdf":
  608. return "application/pdf"
  609. default:
  610. return "text/plain"
  611. }
  612. }
  613. func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
  614. ext := strings.ToLower(filepath.Ext(filePath))
  615. mediaType := getMediaTypeFromExtension(ext)
  616. absolutePath := filePath
  617. if !filepath.IsAbs(filePath) {
  618. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  619. }
  620. // For text files, create a simple file reference
  621. if mediaType == "text/plain" {
  622. return &attachment.Attachment{
  623. ID: uuid.NewString(),
  624. Type: "file",
  625. Display: "@" + filePath,
  626. URL: fmt.Sprintf("file://./%s", filePath),
  627. Filename: filePath,
  628. MediaType: mediaType,
  629. Source: &attachment.FileSource{
  630. Path: absolutePath,
  631. Mime: mediaType,
  632. },
  633. }
  634. }
  635. // For binary files (images, PDFs), read and encode
  636. fileBytes, err := os.ReadFile(filePath)
  637. if err != nil {
  638. slog.Error("Failed to read file", "error", err)
  639. return nil
  640. }
  641. base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
  642. url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
  643. attachmentCount := len(m.textarea.GetAttachments())
  644. attachmentIndex := attachmentCount + 1
  645. label := "File"
  646. if strings.HasPrefix(mediaType, "image/") {
  647. label = "Image"
  648. }
  649. return &attachment.Attachment{
  650. ID: uuid.NewString(),
  651. Type: "file",
  652. MediaType: mediaType,
  653. Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
  654. URL: url,
  655. Filename: filePath,
  656. Source: &attachment.FileSource{
  657. Path: absolutePath,
  658. Mime: mediaType,
  659. Data: fileBytes,
  660. },
  661. }
  662. }
  663. func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
  664. extension := filepath.Ext(filePath)
  665. mediaType := getMediaTypeFromExtension(extension)
  666. absolutePath := filePath
  667. if !filepath.IsAbs(filePath) {
  668. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  669. }
  670. return &attachment.Attachment{
  671. ID: uuid.NewString(),
  672. Type: "file",
  673. Display: "@" + filePath,
  674. URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
  675. Filename: filePath,
  676. MediaType: mediaType,
  677. Source: &attachment.FileSource{
  678. Path: absolutePath,
  679. Mime: mediaType,
  680. },
  681. }
  682. }