editor.go 23 KB

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