editor.go 25 KB

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