editor.go 20 KB

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