editor.go 26 KB

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