editor.go 22 KB

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