editor.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  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. default:
  276. slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
  277. return m, nil
  278. }
  279. }
  280. m.spinner, cmd = m.spinner.Update(msg)
  281. cmds = append(cmds, cmd)
  282. m.textarea, cmd = m.textarea.Update(msg)
  283. cmds = append(cmds, cmd)
  284. return m, tea.Batch(cmds...)
  285. }
  286. func (m *editorComponent) Content() string {
  287. width := m.width
  288. if m.app.Session.ID == "" {
  289. width = min(width, 80)
  290. }
  291. t := theme.CurrentTheme()
  292. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  293. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  294. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  295. Padding(0, 0, 0, 1).
  296. Bold(true)
  297. prompt := promptStyle.Render(">")
  298. m.textarea.SetWidth(width - 6)
  299. textarea := lipgloss.JoinHorizontal(
  300. lipgloss.Top,
  301. prompt,
  302. m.textarea.View(),
  303. )
  304. borderForeground := t.Border()
  305. if m.app.IsLeaderSequence {
  306. borderForeground = t.Accent()
  307. }
  308. textarea = styles.NewStyle().
  309. Background(t.BackgroundElement()).
  310. Width(width).
  311. PaddingTop(1).
  312. PaddingBottom(1).
  313. BorderStyle(lipgloss.ThickBorder()).
  314. BorderForeground(borderForeground).
  315. BorderBackground(t.Background()).
  316. BorderLeft(true).
  317. BorderRight(true).
  318. Render(textarea)
  319. hint := base(m.getSubmitKeyText()) + muted(" send ")
  320. if m.exitKeyInDebounce {
  321. keyText := m.getExitKeyText()
  322. hint = base(keyText+" again") + muted(" to exit")
  323. } else if m.app.IsBusy() {
  324. keyText := m.getInterruptKeyText()
  325. status := "working"
  326. if m.app.CurrentPermission.ID != "" {
  327. status = "waiting for permission"
  328. }
  329. if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
  330. hint = muted(
  331. status,
  332. ) + m.spinner.View() + muted(
  333. " ",
  334. ) + base(
  335. keyText+" again",
  336. ) + muted(
  337. " interrupt",
  338. )
  339. } else {
  340. hint = muted(status) + m.spinner.View()
  341. if m.app.CurrentPermission.ID == "" {
  342. hint += muted(" ") + base(keyText) + muted(" interrupt")
  343. }
  344. }
  345. }
  346. model := ""
  347. if m.app.Model != nil {
  348. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  349. }
  350. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  351. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  352. info := hint + spacer + model
  353. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  354. content := strings.Join([]string{"", textarea, info}, "\n")
  355. return content
  356. }
  357. func (m *editorComponent) View() string {
  358. width := m.width
  359. if m.app.Session.ID == "" {
  360. width = min(width, 80)
  361. }
  362. if m.Lines() > 1 {
  363. return lipgloss.Place(
  364. width,
  365. 5,
  366. lipgloss.Center,
  367. lipgloss.Center,
  368. "",
  369. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  370. )
  371. }
  372. return m.Content()
  373. }
  374. func (m *editorComponent) Focused() bool {
  375. return m.textarea.Focused()
  376. }
  377. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  378. return m, m.textarea.Focus()
  379. }
  380. func (m *editorComponent) Blur() {
  381. m.textarea.Blur()
  382. }
  383. func (m *editorComponent) Lines() int {
  384. return m.textarea.LineCount()
  385. }
  386. func (m *editorComponent) Value() string {
  387. return m.textarea.Value()
  388. }
  389. func (m *editorComponent) Length() int {
  390. return m.textarea.Length()
  391. }
  392. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  393. value := strings.TrimSpace(m.Value())
  394. if value == "" {
  395. return m, nil
  396. }
  397. switch value {
  398. case "exit", "quit", "q", ":q":
  399. return m, tea.Quit
  400. }
  401. if len(value) > 0 && value[len(value)-1] == '\\' {
  402. // If the last character is a backslash, remove it and add a newline
  403. backslashCol := m.textarea.CurrentRowLength() - 1
  404. m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
  405. m.textarea.InsertString("\n")
  406. return m, nil
  407. }
  408. var cmds []tea.Cmd
  409. attachments := m.textarea.GetAttachments()
  410. prompt := app.Prompt{Text: value, Attachments: attachments}
  411. m.app.State.AddPromptToHistory(prompt)
  412. cmds = append(cmds, m.app.SaveState())
  413. updated, cmd := m.Clear()
  414. m = updated.(*editorComponent)
  415. cmds = append(cmds, cmd)
  416. cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
  417. return m, tea.Batch(cmds...)
  418. }
  419. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  420. m.textarea.Reset()
  421. m.historyIndex = -1
  422. m.currentText = ""
  423. m.pasteCounter = 0
  424. return m, nil
  425. }
  426. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  427. imageBytes := clipboard.Read(clipboard.FmtImage)
  428. if imageBytes != nil {
  429. attachmentCount := len(m.textarea.GetAttachments())
  430. attachmentIndex := attachmentCount + 1
  431. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  432. attachment := &attachment.Attachment{
  433. ID: uuid.NewString(),
  434. Type: "file",
  435. MediaType: "image/png",
  436. Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
  437. Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
  438. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  439. Source: &attachment.FileSource{
  440. Path: fmt.Sprintf("image-%d.png", attachmentIndex),
  441. Mime: "image/png",
  442. Data: imageBytes,
  443. },
  444. }
  445. m.textarea.InsertAttachment(attachment)
  446. m.textarea.InsertString(" ")
  447. return m, nil
  448. }
  449. textBytes := clipboard.Read(clipboard.FmtText)
  450. if textBytes != nil {
  451. text := string(textBytes)
  452. // Check if the pasted text is long and should be summarized
  453. if m.shouldSummarizePastedText(text) {
  454. m.handleLongPaste(text)
  455. } else {
  456. m.textarea.InsertRunesFromUserInput([]rune(text))
  457. }
  458. return m, nil
  459. }
  460. // fallback to reading the clipboard using OSC52
  461. return m, tea.ReadClipboard
  462. }
  463. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  464. m.textarea.Newline()
  465. return m, nil
  466. }
  467. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  468. m.interruptKeyInDebounce = inDebounce
  469. }
  470. func (m *editorComponent) SetValue(value string) {
  471. m.textarea.SetValue(value)
  472. }
  473. func (m *editorComponent) SetValueWithAttachments(value string) {
  474. m.textarea.Reset()
  475. i := 0
  476. for i < len(value) {
  477. r, size := utf8.DecodeRuneInString(value[i:])
  478. // Check if filepath and add attachment
  479. if r == '@' {
  480. start := i + size
  481. end := start
  482. for end < len(value) {
  483. nextR, nextSize := utf8.DecodeRuneInString(value[end:])
  484. if nextR == ' ' || nextR == '\t' || nextR == '\n' || nextR == '\r' {
  485. break
  486. }
  487. end += nextSize
  488. }
  489. if end > start {
  490. filePath := value[start:end]
  491. slog.Debug("test", "filePath", filePath)
  492. if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
  493. slog.Debug("test", "found", true)
  494. attachment := m.createAttachmentFromFile(filePath)
  495. if attachment != nil {
  496. m.textarea.InsertAttachment(attachment)
  497. i = end
  498. continue
  499. }
  500. }
  501. }
  502. }
  503. // Not a valid file path, insert the character normally
  504. m.textarea.InsertRune(r)
  505. i += size
  506. }
  507. }
  508. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  509. m.exitKeyInDebounce = inDebounce
  510. }
  511. func (m *editorComponent) getInterruptKeyText() string {
  512. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  513. }
  514. func (m *editorComponent) getSubmitKeyText() string {
  515. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  516. }
  517. func (m *editorComponent) getExitKeyText() string {
  518. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  519. }
  520. // shouldSummarizePastedText determines if pasted text should be summarized
  521. func (m *editorComponent) shouldSummarizePastedText(text string) bool {
  522. lines := strings.Split(text, "\n")
  523. lineCount := len(lines)
  524. charCount := len(text)
  525. // Consider text long if it has more than 3 lines or more than 150 characters
  526. return lineCount > 3 || charCount > 150
  527. }
  528. // handleLongPaste handles long pasted text by creating a summary attachment
  529. func (m *editorComponent) handleLongPaste(text string) {
  530. lines := strings.Split(text, "\n")
  531. lineCount := len(lines)
  532. // Increment paste counter
  533. m.pasteCounter++
  534. // Create attachment with full text as base64 encoded data
  535. fileBytes := []byte(text)
  536. base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
  537. url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
  538. fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
  539. displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
  540. attachment := &attachment.Attachment{
  541. ID: uuid.NewString(),
  542. Type: "text",
  543. MediaType: "text/plain",
  544. Display: displayText,
  545. URL: url,
  546. Filename: fileName,
  547. Source: &attachment.TextSource{
  548. Value: text,
  549. },
  550. }
  551. m.textarea.InsertAttachment(attachment)
  552. m.textarea.InsertString(" ")
  553. }
  554. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  555. t := theme.CurrentTheme()
  556. bgColor := t.BackgroundElement()
  557. textColor := t.Text()
  558. textMutedColor := t.TextMuted()
  559. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  560. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  561. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  562. Foreground(textMutedColor).
  563. Background(bgColor).
  564. Lipgloss()
  565. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  566. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  567. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  568. ta.Styles.Focused.Placeholder = styles.NewStyle().
  569. Foreground(textMutedColor).
  570. Background(bgColor).
  571. Lipgloss()
  572. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  573. ta.Styles.Attachment = styles.NewStyle().
  574. Foreground(t.Secondary()).
  575. Background(bgColor).
  576. Lipgloss()
  577. ta.Styles.SelectedAttachment = styles.NewStyle().
  578. Foreground(t.Text()).
  579. Background(t.Secondary()).
  580. Lipgloss()
  581. ta.Styles.Cursor.Color = t.Primary()
  582. return ta
  583. }
  584. func createSpinner() spinner.Model {
  585. t := theme.CurrentTheme()
  586. return spinner.New(
  587. spinner.WithSpinner(spinner.Ellipsis),
  588. spinner.WithStyle(
  589. styles.NewStyle().
  590. Background(t.Background()).
  591. Foreground(t.TextMuted()).
  592. Width(3).
  593. Lipgloss(),
  594. ),
  595. )
  596. }
  597. func NewEditorComponent(app *app.App) EditorComponent {
  598. s := createSpinner()
  599. ta := textarea.New()
  600. ta.Prompt = " "
  601. ta.ShowLineNumbers = false
  602. ta.CharLimit = -1
  603. ta = updateTextareaStyles(ta)
  604. m := &editorComponent{
  605. app: app,
  606. textarea: ta,
  607. spinner: s,
  608. interruptKeyInDebounce: false,
  609. historyIndex: -1,
  610. pasteCounter: 0,
  611. }
  612. return m
  613. }
  614. func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
  615. m.textarea.Reset()
  616. m.textarea.SetValue(prompt.Text)
  617. // Sort attachments by start index in reverse order (process from end to beginning)
  618. // This prevents index shifting issues
  619. attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
  620. copy(attachmentsCopy, prompt.Attachments)
  621. for i := 0; i < len(attachmentsCopy)-1; i++ {
  622. for j := i + 1; j < len(attachmentsCopy); j++ {
  623. if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
  624. attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
  625. }
  626. }
  627. }
  628. for _, att := range attachmentsCopy {
  629. m.textarea.SetCursorColumn(att.StartIndex)
  630. m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
  631. m.textarea.InsertAttachment(att)
  632. }
  633. }
  634. // RestoreFromHistory restores a message from history at the given index
  635. func (m *editorComponent) RestoreFromHistory(index int) {
  636. if index < 0 || index >= len(m.app.State.MessageHistory) {
  637. return
  638. }
  639. entry := m.app.State.MessageHistory[index]
  640. m.RestoreFromPrompt(entry)
  641. }
  642. func getMediaTypeFromExtension(ext string) string {
  643. switch strings.ToLower(ext) {
  644. case ".jpg":
  645. return "image/jpeg"
  646. case ".png", ".jpeg", ".gif", ".webp":
  647. return "image/" + ext[1:]
  648. case ".pdf":
  649. return "application/pdf"
  650. default:
  651. return "text/plain"
  652. }
  653. }
  654. func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
  655. ext := strings.ToLower(filepath.Ext(filePath))
  656. mediaType := getMediaTypeFromExtension(ext)
  657. absolutePath := filePath
  658. if !filepath.IsAbs(filePath) {
  659. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  660. }
  661. // For text files, create a simple file reference
  662. if mediaType == "text/plain" {
  663. return &attachment.Attachment{
  664. ID: uuid.NewString(),
  665. Type: "file",
  666. Display: "@" + filePath,
  667. URL: fmt.Sprintf("file://%s", absolutePath),
  668. Filename: filePath,
  669. MediaType: mediaType,
  670. Source: &attachment.FileSource{
  671. Path: absolutePath,
  672. Mime: mediaType,
  673. },
  674. }
  675. }
  676. // For binary files (images, PDFs), read and encode
  677. fileBytes, err := os.ReadFile(filePath)
  678. if err != nil {
  679. slog.Error("Failed to read file", "error", err)
  680. return nil
  681. }
  682. base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
  683. url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
  684. attachmentCount := len(m.textarea.GetAttachments())
  685. attachmentIndex := attachmentCount + 1
  686. label := "File"
  687. if strings.HasPrefix(mediaType, "image/") {
  688. label = "Image"
  689. }
  690. return &attachment.Attachment{
  691. ID: uuid.NewString(),
  692. Type: "file",
  693. MediaType: mediaType,
  694. Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
  695. URL: url,
  696. Filename: filePath,
  697. Source: &attachment.FileSource{
  698. Path: absolutePath,
  699. Mime: mediaType,
  700. Data: fileBytes,
  701. },
  702. }
  703. }
  704. func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
  705. extension := filepath.Ext(filePath)
  706. mediaType := getMediaTypeFromExtension(extension)
  707. absolutePath := filePath
  708. if !filepath.IsAbs(filePath) {
  709. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  710. }
  711. return &attachment.Attachment{
  712. ID: uuid.NewString(),
  713. Type: "file",
  714. Display: "@" + filePath,
  715. URL: fmt.Sprintf("file://%s", absolutePath),
  716. Filename: filePath,
  717. MediaType: mediaType,
  718. Source: &attachment.FileSource{
  719. Path: absolutePath,
  720. Mime: mediaType,
  721. },
  722. }
  723. }