editor.go 23 KB

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