editor.go 25 KB

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