editor.go 22 KB

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