editor.go 24 KB

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