editor.go 23 KB

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