editor.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  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. slog.Debug("test", "filePath", filePath)
  440. if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
  441. slog.Debug("test", "found", true)
  442. attachment := m.createAttachmentFromFile(filePath)
  443. if attachment != nil {
  444. m.textarea.InsertAttachment(attachment)
  445. i = end
  446. continue
  447. }
  448. }
  449. }
  450. }
  451. // Not a valid file path, insert the character normally
  452. m.textarea.InsertRune(rune(value[i]))
  453. i++
  454. }
  455. }
  456. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  457. m.exitKeyInDebounce = inDebounce
  458. }
  459. func (m *editorComponent) getInterruptKeyText() string {
  460. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  461. }
  462. func (m *editorComponent) getSubmitKeyText() string {
  463. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  464. }
  465. func (m *editorComponent) getExitKeyText() string {
  466. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  467. }
  468. // shouldSummarizePastedText determines if pasted text should be summarized
  469. func (m *editorComponent) shouldSummarizePastedText(text string) bool {
  470. lines := strings.Split(text, "\n")
  471. lineCount := len(lines)
  472. charCount := len(text)
  473. // Consider text long if it has more than 3 lines or more than 150 characters
  474. return lineCount > 3 || charCount > 150
  475. }
  476. // handleLongPaste handles long pasted text by creating a summary attachment
  477. func (m *editorComponent) handleLongPaste(text string) {
  478. lines := strings.Split(text, "\n")
  479. lineCount := len(lines)
  480. // Increment paste counter
  481. m.pasteCounter++
  482. // Create attachment with full text as base64 encoded data
  483. fileBytes := []byte(text)
  484. base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
  485. url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
  486. fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
  487. displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
  488. attachment := &attachment.Attachment{
  489. ID: uuid.NewString(),
  490. Type: "text",
  491. MediaType: "text/plain",
  492. Display: displayText,
  493. URL: url,
  494. Filename: fileName,
  495. Source: &attachment.TextSource{
  496. Value: text,
  497. },
  498. }
  499. m.textarea.InsertAttachment(attachment)
  500. m.textarea.InsertString(" ")
  501. }
  502. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  503. t := theme.CurrentTheme()
  504. bgColor := t.BackgroundElement()
  505. textColor := t.Text()
  506. textMutedColor := t.TextMuted()
  507. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  508. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  509. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  510. Foreground(textMutedColor).
  511. Background(bgColor).
  512. Lipgloss()
  513. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  514. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  515. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  516. ta.Styles.Focused.Placeholder = styles.NewStyle().
  517. Foreground(textMutedColor).
  518. Background(bgColor).
  519. Lipgloss()
  520. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  521. ta.Styles.Attachment = styles.NewStyle().
  522. Foreground(t.Secondary()).
  523. Background(bgColor).
  524. Lipgloss()
  525. ta.Styles.SelectedAttachment = styles.NewStyle().
  526. Foreground(t.Text()).
  527. Background(t.Secondary()).
  528. Lipgloss()
  529. ta.Styles.Cursor.Color = t.Primary()
  530. return ta
  531. }
  532. func createSpinner() spinner.Model {
  533. t := theme.CurrentTheme()
  534. return spinner.New(
  535. spinner.WithSpinner(spinner.Ellipsis),
  536. spinner.WithStyle(
  537. styles.NewStyle().
  538. Background(t.Background()).
  539. Foreground(t.TextMuted()).
  540. Width(3).
  541. Lipgloss(),
  542. ),
  543. )
  544. }
  545. func NewEditorComponent(app *app.App) EditorComponent {
  546. s := createSpinner()
  547. ta := textarea.New()
  548. ta.Prompt = " "
  549. ta.ShowLineNumbers = false
  550. ta.CharLimit = -1
  551. ta = updateTextareaStyles(ta)
  552. m := &editorComponent{
  553. app: app,
  554. textarea: ta,
  555. spinner: s,
  556. interruptKeyInDebounce: false,
  557. historyIndex: -1,
  558. pasteCounter: 0,
  559. }
  560. return m
  561. }
  562. // RestoreFromHistory restores a message from history at the given index
  563. func (m *editorComponent) RestoreFromHistory(index int) {
  564. if index < 0 || index >= len(m.app.State.MessageHistory) {
  565. return
  566. }
  567. entry := m.app.State.MessageHistory[index]
  568. m.textarea.Reset()
  569. m.textarea.SetValue(entry.Text)
  570. // Sort attachments by start index in reverse order (process from end to beginning)
  571. // This prevents index shifting issues
  572. attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
  573. copy(attachmentsCopy, entry.Attachments)
  574. for i := 0; i < len(attachmentsCopy)-1; i++ {
  575. for j := i + 1; j < len(attachmentsCopy); j++ {
  576. if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
  577. attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
  578. }
  579. }
  580. }
  581. for _, att := range attachmentsCopy {
  582. m.textarea.SetCursorColumn(att.StartIndex)
  583. m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
  584. m.textarea.InsertAttachment(att)
  585. }
  586. }
  587. func getMediaTypeFromExtension(ext string) string {
  588. switch strings.ToLower(ext) {
  589. case ".jpg":
  590. return "image/jpeg"
  591. case ".png", ".jpeg", ".gif", ".webp":
  592. return "image/" + ext[1:]
  593. case ".pdf":
  594. return "application/pdf"
  595. default:
  596. return "text/plain"
  597. }
  598. }
  599. func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
  600. ext := strings.ToLower(filepath.Ext(filePath))
  601. mediaType := getMediaTypeFromExtension(ext)
  602. absolutePath := filePath
  603. if !filepath.IsAbs(filePath) {
  604. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  605. }
  606. // For text files, create a simple file reference
  607. if mediaType == "text/plain" {
  608. return &attachment.Attachment{
  609. ID: uuid.NewString(),
  610. Type: "file",
  611. Display: "@" + filePath,
  612. URL: fmt.Sprintf("file://./%s", filePath),
  613. Filename: filePath,
  614. MediaType: mediaType,
  615. Source: &attachment.FileSource{
  616. Path: absolutePath,
  617. Mime: mediaType,
  618. },
  619. }
  620. }
  621. // For binary files (images, PDFs), read and encode
  622. fileBytes, err := os.ReadFile(filePath)
  623. if err != nil {
  624. slog.Error("Failed to read file", "error", err)
  625. return nil
  626. }
  627. base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
  628. url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
  629. attachmentCount := len(m.textarea.GetAttachments())
  630. attachmentIndex := attachmentCount + 1
  631. label := "File"
  632. if strings.HasPrefix(mediaType, "image/") {
  633. label = "Image"
  634. }
  635. return &attachment.Attachment{
  636. ID: uuid.NewString(),
  637. Type: "file",
  638. MediaType: mediaType,
  639. Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
  640. URL: url,
  641. Filename: filePath,
  642. Source: &attachment.FileSource{
  643. Path: absolutePath,
  644. Mime: mediaType,
  645. Data: fileBytes,
  646. },
  647. }
  648. }
  649. func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
  650. extension := filepath.Ext(filePath)
  651. mediaType := getMediaTypeFromExtension(extension)
  652. absolutePath := filePath
  653. if !filepath.IsAbs(filePath) {
  654. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  655. }
  656. return &attachment.Attachment{
  657. ID: uuid.NewString(),
  658. Type: "file",
  659. Display: "@" + filePath,
  660. URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
  661. Filename: filePath,
  662. MediaType: mediaType,
  663. Source: &attachment.FileSource{
  664. Path: absolutePath,
  665. Mime: mediaType,
  666. },
  667. }
  668. }