chat.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  1. package chat
  2. import (
  3. "context"
  4. "strings"
  5. "time"
  6. "github.com/atotto/clipboard"
  7. "github.com/charmbracelet/bubbles/v2/key"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/crush/internal/agent"
  10. "github.com/charmbracelet/crush/internal/agent/tools"
  11. "github.com/charmbracelet/crush/internal/app"
  12. "github.com/charmbracelet/crush/internal/message"
  13. "github.com/charmbracelet/crush/internal/permission"
  14. "github.com/charmbracelet/crush/internal/pubsub"
  15. "github.com/charmbracelet/crush/internal/session"
  16. "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
  17. "github.com/charmbracelet/crush/internal/tui/components/core/layout"
  18. "github.com/charmbracelet/crush/internal/tui/exp/list"
  19. "github.com/charmbracelet/crush/internal/tui/styles"
  20. "github.com/charmbracelet/crush/internal/tui/util"
  21. )
  22. type SendMsg struct {
  23. Text string
  24. Attachments []message.Attachment
  25. }
  26. type SessionSelectedMsg = session.Session
  27. type SessionClearedMsg struct{}
  28. type SelectionCopyMsg struct {
  29. clickCount int
  30. endSelection bool
  31. x, y int
  32. }
  33. const (
  34. NotFound = -1
  35. )
  36. // MessageListCmp represents a component that displays a list of chat messages
  37. // with support for real-time updates and session management.
  38. type MessageListCmp interface {
  39. util.Model
  40. layout.Sizeable
  41. layout.Focusable
  42. layout.Help
  43. SetSession(session.Session) tea.Cmd
  44. GoToBottom() tea.Cmd
  45. GetSelectedText() string
  46. CopySelectedText(bool) tea.Cmd
  47. }
  48. // messageListCmp implements MessageListCmp, providing a virtualized list
  49. // of chat messages with support for tool calls, real-time updates, and
  50. // session switching.
  51. type messageListCmp struct {
  52. app *app.App
  53. width, height int
  54. session session.Session
  55. listCmp list.List[list.Item]
  56. previousSelected string // Last selected item index for restoring focus
  57. lastUserMessageTime int64
  58. defaultListKeyMap list.KeyMap
  59. // Click tracking for double/triple click detection
  60. lastClickTime time.Time
  61. lastClickX int
  62. lastClickY int
  63. clickCount int
  64. promptQueue int
  65. }
  66. // New creates a new message list component with custom keybindings
  67. // and reverse ordering (newest messages at bottom).
  68. func New(app *app.App) MessageListCmp {
  69. defaultListKeyMap := list.DefaultKeyMap()
  70. listCmp := list.New(
  71. []list.Item{},
  72. list.WithGap(1),
  73. list.WithDirectionBackward(),
  74. list.WithFocus(false),
  75. list.WithKeyMap(defaultListKeyMap),
  76. list.WithEnableMouse(),
  77. )
  78. return &messageListCmp{
  79. app: app,
  80. listCmp: listCmp,
  81. previousSelected: "",
  82. defaultListKeyMap: defaultListKeyMap,
  83. }
  84. }
  85. // Init initializes the component.
  86. func (m *messageListCmp) Init() tea.Cmd {
  87. return m.listCmp.Init()
  88. }
  89. // Update handles incoming messages and updates the component state.
  90. func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
  91. var cmds []tea.Cmd
  92. if m.session.ID != "" && m.app.AgentCoordinator != nil {
  93. queueSize := m.app.AgentCoordinator.QueuedPrompts(m.session.ID)
  94. if queueSize != m.promptQueue {
  95. m.promptQueue = queueSize
  96. cmds = append(cmds, m.SetSize(m.width, m.height))
  97. }
  98. }
  99. switch msg := msg.(type) {
  100. case tea.KeyPressMsg:
  101. if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
  102. switch {
  103. case key.Matches(msg, messages.CopyKey):
  104. cmds = append(cmds, m.CopySelectedText(true))
  105. return m, tea.Batch(cmds...)
  106. case key.Matches(msg, messages.ClearSelectionKey):
  107. cmds = append(cmds, m.SelectionClear())
  108. return m, tea.Batch(cmds...)
  109. }
  110. }
  111. case tea.MouseClickMsg:
  112. x := msg.X - 1 // Adjust for padding
  113. y := msg.Y - 1 // Adjust for padding
  114. if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
  115. return m, nil // Ignore clicks outside the component
  116. }
  117. if msg.Button == tea.MouseLeft {
  118. cmds = append(cmds, m.handleMouseClick(x, y))
  119. return m, tea.Batch(cmds...)
  120. }
  121. return m, tea.Batch(cmds...)
  122. case tea.MouseMotionMsg:
  123. x := msg.X - 1 // Adjust for padding
  124. y := msg.Y - 1 // Adjust for padding
  125. if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
  126. if y < 0 {
  127. cmds = append(cmds, m.listCmp.MoveUp(1))
  128. return m, tea.Batch(cmds...)
  129. }
  130. if y >= m.height-1 {
  131. cmds = append(cmds, m.listCmp.MoveDown(1))
  132. return m, tea.Batch(cmds...)
  133. }
  134. return m, nil // Ignore clicks outside the component
  135. }
  136. if msg.Button == tea.MouseLeft {
  137. m.listCmp.EndSelection(x, y)
  138. }
  139. return m, tea.Batch(cmds...)
  140. case tea.MouseReleaseMsg:
  141. x := msg.X - 1 // Adjust for padding
  142. y := msg.Y - 1 // Adjust for padding
  143. if msg.Button == tea.MouseLeft {
  144. clickCount := m.clickCount
  145. if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
  146. tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
  147. return SelectionCopyMsg{
  148. clickCount: clickCount,
  149. endSelection: false,
  150. }
  151. })
  152. cmds = append(cmds, tick)
  153. return m, tea.Batch(cmds...)
  154. }
  155. tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
  156. return SelectionCopyMsg{
  157. clickCount: clickCount,
  158. endSelection: true,
  159. x: x,
  160. y: y,
  161. }
  162. })
  163. cmds = append(cmds, tick)
  164. return m, tea.Batch(cmds...)
  165. }
  166. return m, nil
  167. case SelectionCopyMsg:
  168. if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
  169. // If the click count matches and within threshold, copy selected text
  170. if msg.endSelection {
  171. m.listCmp.EndSelection(msg.x, msg.y)
  172. }
  173. m.listCmp.SelectionStop()
  174. cmds = append(cmds, m.CopySelectedText(true))
  175. return m, tea.Batch(cmds...)
  176. }
  177. case pubsub.Event[permission.PermissionNotification]:
  178. cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
  179. return m, tea.Batch(cmds...)
  180. case SessionSelectedMsg:
  181. if msg.ID != m.session.ID {
  182. cmds = append(cmds, m.SetSession(msg))
  183. }
  184. return m, tea.Batch(cmds...)
  185. case SessionClearedMsg:
  186. m.session = session.Session{}
  187. cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
  188. return m, tea.Batch(cmds...)
  189. case pubsub.Event[message.Message]:
  190. cmds = append(cmds, m.handleMessageEvent(msg))
  191. return m, tea.Batch(cmds...)
  192. case tea.MouseWheelMsg:
  193. u, cmd := m.listCmp.Update(msg)
  194. m.listCmp = u.(list.List[list.Item])
  195. cmds = append(cmds, cmd)
  196. return m, tea.Batch(cmds...)
  197. }
  198. u, cmd := m.listCmp.Update(msg)
  199. m.listCmp = u.(list.List[list.Item])
  200. cmds = append(cmds, cmd)
  201. return m, tea.Batch(cmds...)
  202. }
  203. // View renders the message list or an initial screen if empty.
  204. func (m *messageListCmp) View() string {
  205. t := styles.CurrentTheme()
  206. height := m.height
  207. if m.promptQueue > 0 {
  208. height -= 4 // pill height and padding
  209. }
  210. view := []string{
  211. t.S().Base.
  212. Padding(1, 1, 0, 1).
  213. Width(m.width).
  214. Height(height).
  215. Render(
  216. m.listCmp.View(),
  217. ),
  218. }
  219. if m.app.AgentCoordinator != nil && m.promptQueue > 0 {
  220. queuePill := queuePill(m.promptQueue, t)
  221. view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
  222. }
  223. return strings.Join(view, "\n")
  224. }
  225. func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
  226. items := m.listCmp.Items()
  227. if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
  228. toolCall := items[toolCallIndex].(messages.ToolCallCmp)
  229. toolCall.SetPermissionRequested()
  230. if permission.Granted {
  231. toolCall.SetPermissionGranted()
  232. }
  233. m.listCmp.UpdateItem(toolCall.ID(), toolCall)
  234. }
  235. return nil
  236. }
  237. // handleChildSession handles messages from child sessions (agent tools).
  238. func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
  239. var cmds []tea.Cmd
  240. if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
  241. return nil
  242. }
  243. // Check if this is an agent tool session and parse it
  244. childSessionID := event.Payload.SessionID
  245. parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
  246. if !ok {
  247. return nil
  248. }
  249. items := m.listCmp.Items()
  250. toolCallInx := NotFound
  251. var toolCall messages.ToolCallCmp
  252. for i := len(items) - 1; i >= 0; i-- {
  253. if msg, ok := items[i].(messages.ToolCallCmp); ok {
  254. if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
  255. toolCallInx = i
  256. toolCall = msg
  257. }
  258. }
  259. }
  260. if toolCallInx == NotFound {
  261. return nil
  262. }
  263. nestedToolCalls := toolCall.GetNestedToolCalls()
  264. for _, tc := range event.Payload.ToolCalls() {
  265. found := false
  266. for existingInx, existingTC := range nestedToolCalls {
  267. if existingTC.GetToolCall().ID == tc.ID {
  268. nestedToolCalls[existingInx].SetToolCall(tc)
  269. found = true
  270. break
  271. }
  272. }
  273. if !found {
  274. nestedCall := messages.NewToolCallCmp(
  275. event.Payload.ID,
  276. tc,
  277. m.app.Permissions,
  278. messages.WithToolCallNested(true),
  279. )
  280. cmds = append(cmds, nestedCall.Init())
  281. nestedToolCalls = append(
  282. nestedToolCalls,
  283. nestedCall,
  284. )
  285. }
  286. }
  287. for _, tr := range event.Payload.ToolResults() {
  288. for nestedInx, nestedTC := range nestedToolCalls {
  289. if nestedTC.GetToolCall().ID == tr.ToolCallID {
  290. nestedToolCalls[nestedInx].SetToolResult(tr)
  291. break
  292. }
  293. }
  294. }
  295. toolCall.SetNestedToolCalls(nestedToolCalls)
  296. m.listCmp.UpdateItem(
  297. toolCall.ID(),
  298. toolCall,
  299. )
  300. return tea.Batch(cmds...)
  301. }
  302. // handleMessageEvent processes different types of message events (created/updated).
  303. func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
  304. switch event.Type {
  305. case pubsub.CreatedEvent:
  306. if event.Payload.SessionID != m.session.ID {
  307. return m.handleChildSession(event)
  308. }
  309. if m.messageExists(event.Payload.ID) {
  310. return nil
  311. }
  312. return m.handleNewMessage(event.Payload)
  313. case pubsub.DeletedEvent:
  314. if event.Payload.SessionID != m.session.ID {
  315. return nil
  316. }
  317. return m.handleDeleteMessage(event.Payload)
  318. case pubsub.UpdatedEvent:
  319. if event.Payload.SessionID != m.session.ID {
  320. return m.handleChildSession(event)
  321. }
  322. switch event.Payload.Role {
  323. case message.Assistant:
  324. return m.handleUpdateAssistantMessage(event.Payload)
  325. case message.Tool:
  326. return m.handleToolMessage(event.Payload)
  327. }
  328. }
  329. return nil
  330. }
  331. // messageExists checks if a message with the given ID already exists in the list.
  332. func (m *messageListCmp) messageExists(messageID string) bool {
  333. items := m.listCmp.Items()
  334. // Search backwards as new messages are more likely to be at the end
  335. for i := len(items) - 1; i >= 0; i-- {
  336. if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
  337. return true
  338. }
  339. }
  340. return false
  341. }
  342. // handleDeleteMessage removes a message from the list.
  343. func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
  344. items := m.listCmp.Items()
  345. for i := len(items) - 1; i >= 0; i-- {
  346. if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
  347. m.listCmp.DeleteItem(items[i].ID())
  348. return nil
  349. }
  350. }
  351. return nil
  352. }
  353. // handleNewMessage routes new messages to appropriate handlers based on role.
  354. func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
  355. switch msg.Role {
  356. case message.User:
  357. return m.handleNewUserMessage(msg)
  358. case message.Assistant:
  359. return m.handleNewAssistantMessage(msg)
  360. case message.Tool:
  361. return m.handleToolMessage(msg)
  362. }
  363. return nil
  364. }
  365. // handleNewUserMessage adds a new user message to the list and updates the timestamp.
  366. func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
  367. m.lastUserMessageTime = msg.CreatedAt
  368. return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
  369. }
  370. // handleToolMessage updates existing tool calls with their results.
  371. func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
  372. items := m.listCmp.Items()
  373. for _, tr := range msg.ToolResults() {
  374. if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
  375. toolCall := items[toolCallIndex].(messages.ToolCallCmp)
  376. toolCall.SetToolResult(tr)
  377. m.listCmp.UpdateItem(toolCall.ID(), toolCall)
  378. }
  379. }
  380. return nil
  381. }
  382. // findToolCallByID searches for a tool call with the specified ID.
  383. // Returns the index if found, NotFound otherwise.
  384. func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
  385. // Search backwards as tool calls are more likely to be recent
  386. for i := len(items) - 1; i >= 0; i-- {
  387. if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
  388. return i
  389. }
  390. }
  391. return NotFound
  392. }
  393. // handleUpdateAssistantMessage processes updates to assistant messages,
  394. // managing both message content and associated tool calls.
  395. func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
  396. var cmds []tea.Cmd
  397. items := m.listCmp.Items()
  398. // Find existing assistant message and tool calls for this message
  399. assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
  400. // Handle assistant message content
  401. if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
  402. cmds = append(cmds, cmd)
  403. }
  404. // Handle tool calls
  405. if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
  406. cmds = append(cmds, cmd)
  407. }
  408. return tea.Batch(cmds...)
  409. }
  410. // findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
  411. func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
  412. assistantIndex := NotFound
  413. toolCalls := make(map[int]messages.ToolCallCmp)
  414. // Search backwards as messages are more likely to be at the end
  415. for i := len(items) - 1; i >= 0; i-- {
  416. item := items[i]
  417. if asMsg, ok := item.(messages.MessageCmp); ok {
  418. if asMsg.GetMessage().ID == messageID {
  419. assistantIndex = i
  420. }
  421. } else if tc, ok := item.(messages.ToolCallCmp); ok {
  422. if tc.ParentMessageID() == messageID {
  423. toolCalls[i] = tc
  424. }
  425. }
  426. }
  427. return assistantIndex, toolCalls
  428. }
  429. // updateAssistantMessageContent updates or removes the assistant message based on content.
  430. func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
  431. if assistantIndex == NotFound {
  432. return nil
  433. }
  434. shouldShowMessage := m.shouldShowAssistantMessage(msg)
  435. hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
  436. var cmd tea.Cmd
  437. if shouldShowMessage {
  438. items := m.listCmp.Items()
  439. uiMsg := items[assistantIndex].(messages.MessageCmp)
  440. uiMsg.SetMessage(msg)
  441. m.listCmp.UpdateItem(
  442. items[assistantIndex].ID(),
  443. uiMsg,
  444. )
  445. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
  446. m.listCmp.AppendItem(
  447. messages.NewAssistantSection(
  448. msg,
  449. time.Unix(m.lastUserMessageTime, 0),
  450. ),
  451. )
  452. }
  453. } else if hasToolCallsOnly {
  454. items := m.listCmp.Items()
  455. m.listCmp.DeleteItem(items[assistantIndex].ID())
  456. }
  457. return cmd
  458. }
  459. // shouldShowAssistantMessage determines if an assistant message should be displayed.
  460. func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
  461. return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
  462. }
  463. // updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
  464. func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
  465. var cmds []tea.Cmd
  466. for _, tc := range msg.ToolCalls() {
  467. if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
  468. cmds = append(cmds, cmd)
  469. }
  470. }
  471. return tea.Batch(cmds...)
  472. }
  473. // updateOrAddToolCall updates an existing tool call or adds a new one.
  474. func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
  475. // Try to find existing tool call
  476. for _, existingTC := range existingToolCalls {
  477. if tc.ID == existingTC.GetToolCall().ID {
  478. existingTC.SetToolCall(tc)
  479. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
  480. existingTC.SetCancelled()
  481. }
  482. m.listCmp.UpdateItem(tc.ID, existingTC)
  483. return nil
  484. }
  485. }
  486. // Add new tool call if not found
  487. return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
  488. }
  489. // handleNewAssistantMessage processes new assistant messages and their tool calls.
  490. func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
  491. var cmds []tea.Cmd
  492. // Add assistant message if it should be displayed
  493. if m.shouldShowAssistantMessage(msg) {
  494. cmd := m.listCmp.AppendItem(
  495. messages.NewMessageCmp(
  496. msg,
  497. ),
  498. )
  499. cmds = append(cmds, cmd)
  500. }
  501. // Add tool calls
  502. for _, tc := range msg.ToolCalls() {
  503. cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
  504. cmds = append(cmds, cmd)
  505. }
  506. return tea.Batch(cmds...)
  507. }
  508. // SetSession loads and displays messages for a new session.
  509. func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
  510. if m.session.ID == session.ID {
  511. return nil
  512. }
  513. m.session = session
  514. sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
  515. if err != nil {
  516. return util.ReportError(err)
  517. }
  518. if len(sessionMessages) == 0 {
  519. return m.listCmp.SetItems([]list.Item{})
  520. }
  521. // Initialize with first message timestamp
  522. m.lastUserMessageTime = sessionMessages[0].CreatedAt
  523. // Build tool result map for efficient lookup
  524. toolResultMap := m.buildToolResultMap(sessionMessages)
  525. // Convert messages to UI components
  526. uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
  527. return m.listCmp.SetItems(uiMessages)
  528. }
  529. // buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
  530. func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
  531. toolResultMap := make(map[string]message.ToolResult)
  532. for _, msg := range messages {
  533. for _, tr := range msg.ToolResults() {
  534. toolResultMap[tr.ToolCallID] = tr
  535. }
  536. }
  537. return toolResultMap
  538. }
  539. // convertMessagesToUI converts database messages to UI components.
  540. func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
  541. uiMessages := make([]list.Item, 0)
  542. for _, msg := range sessionMessages {
  543. switch msg.Role {
  544. case message.User:
  545. m.lastUserMessageTime = msg.CreatedAt
  546. uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
  547. case message.Assistant:
  548. uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
  549. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
  550. uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
  551. }
  552. }
  553. }
  554. return uiMessages
  555. }
  556. // convertAssistantMessage converts an assistant message and its tool calls to UI components.
  557. func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
  558. var uiMessages []list.Item
  559. // Add assistant message if it should be displayed
  560. if m.shouldShowAssistantMessage(msg) {
  561. uiMessages = append(
  562. uiMessages,
  563. messages.NewMessageCmp(
  564. msg,
  565. ),
  566. )
  567. }
  568. // Add tool calls with their results and status
  569. for _, tc := range msg.ToolCalls() {
  570. options := m.buildToolCallOptions(tc, msg, toolResultMap)
  571. uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
  572. // If this tool call is the agent tool or agentic fetch, fetch nested tool calls
  573. if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
  574. agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
  575. nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
  576. nestedToolResultMap := m.buildToolResultMap(nestedMessages)
  577. nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
  578. nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
  579. for _, nestedMsg := range nestedUIMessages {
  580. if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
  581. toolCall.SetIsNested(true)
  582. nestedToolCalls = append(nestedToolCalls, toolCall)
  583. }
  584. }
  585. uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
  586. }
  587. }
  588. return uiMessages
  589. }
  590. // buildToolCallOptions creates options for tool call components based on results and status.
  591. func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
  592. var options []messages.ToolCallOption
  593. // Add tool result if available
  594. if tr, ok := toolResultMap[tc.ID]; ok {
  595. options = append(options, messages.WithToolCallResult(tr))
  596. }
  597. // Add cancelled status if applicable
  598. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
  599. options = append(options, messages.WithToolCallCancelled())
  600. }
  601. return options
  602. }
  603. // GetSize returns the current width and height of the component.
  604. func (m *messageListCmp) GetSize() (int, int) {
  605. return m.width, m.height
  606. }
  607. // SetSize updates the component dimensions and propagates to the list component.
  608. func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
  609. m.width = width
  610. m.height = height
  611. if m.promptQueue > 0 {
  612. queueHeight := 3 + 1 // 1 for padding top
  613. lHight := max(0, height-(1+queueHeight))
  614. return m.listCmp.SetSize(width-2, lHight)
  615. }
  616. return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
  617. }
  618. // Blur implements MessageListCmp.
  619. func (m *messageListCmp) Blur() tea.Cmd {
  620. return m.listCmp.Blur()
  621. }
  622. // Focus implements MessageListCmp.
  623. func (m *messageListCmp) Focus() tea.Cmd {
  624. return m.listCmp.Focus()
  625. }
  626. // IsFocused implements MessageListCmp.
  627. func (m *messageListCmp) IsFocused() bool {
  628. return m.listCmp.IsFocused()
  629. }
  630. func (m *messageListCmp) Bindings() []key.Binding {
  631. return m.defaultListKeyMap.KeyBindings()
  632. }
  633. func (m *messageListCmp) GoToBottom() tea.Cmd {
  634. return m.listCmp.GoToBottom()
  635. }
  636. const (
  637. doubleClickThreshold = 500 * time.Millisecond
  638. clickTolerance = 2 // pixels
  639. )
  640. // handleMouseClick handles mouse click events and detects double/triple clicks.
  641. func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
  642. now := time.Now()
  643. // Check if this is a potential multi-click
  644. if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
  645. abs(x-m.lastClickX) <= clickTolerance &&
  646. abs(y-m.lastClickY) <= clickTolerance {
  647. m.clickCount++
  648. } else {
  649. m.clickCount = 1
  650. }
  651. m.lastClickTime = now
  652. m.lastClickX = x
  653. m.lastClickY = y
  654. switch m.clickCount {
  655. case 1:
  656. // Single click - start selection
  657. m.listCmp.StartSelection(x, y)
  658. case 2:
  659. // Double click - select word
  660. m.listCmp.SelectWord(x, y)
  661. case 3:
  662. // Triple click - select paragraph
  663. m.listCmp.SelectParagraph(x, y)
  664. m.clickCount = 0 // Reset after triple click
  665. }
  666. return nil
  667. }
  668. // SelectionClear clears the current selection in the list component.
  669. func (m *messageListCmp) SelectionClear() tea.Cmd {
  670. m.listCmp.SelectionClear()
  671. m.previousSelected = ""
  672. m.lastClickX, m.lastClickY = 0, 0
  673. m.lastClickTime = time.Time{}
  674. m.clickCount = 0
  675. return nil
  676. }
  677. // HasSelection checks if there is a selection in the list component.
  678. func (m *messageListCmp) HasSelection() bool {
  679. return m.listCmp.HasSelection()
  680. }
  681. // GetSelectedText returns the currently selected text from the list component.
  682. func (m *messageListCmp) GetSelectedText() string {
  683. return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
  684. }
  685. // CopySelectedText copies the currently selected text to the clipboard. When
  686. // clear is true, it clears the selection after copying.
  687. func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
  688. if !m.listCmp.HasSelection() {
  689. return nil
  690. }
  691. selectedText := m.GetSelectedText()
  692. if selectedText == "" {
  693. return util.ReportInfo("No text selected")
  694. }
  695. if clear {
  696. defer func() { m.SelectionClear() }()
  697. }
  698. return tea.Sequence(
  699. // We use both OSC 52 and native clipboard for compatibility with different
  700. // terminal emulators and environments.
  701. tea.SetClipboard(selectedText),
  702. func() tea.Msg {
  703. _ = clipboard.WriteAll(selectedText)
  704. return nil
  705. },
  706. util.ReportInfo("Selected text copied to clipboard"),
  707. )
  708. }
  709. // abs returns the absolute value of an integer.
  710. func abs(x int) int {
  711. if x < 0 {
  712. return -x
  713. }
  714. return x
  715. }