editor.go 24 KB

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