useMessageHandlers.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import { useCallback, useRef } from "react"
  2. import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
  3. import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli"
  4. import type { TUIMessage, ToolData } from "../types.js"
  5. import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
  6. import { useCLIStore } from "../store.js"
  7. import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js"
  8. export interface UseMessageHandlersOptions {
  9. nonInteractive: boolean
  10. }
  11. export interface UseMessageHandlersReturn {
  12. handleExtensionMessage: (msg: ExtensionMessage) => void
  13. seenMessageIds: React.MutableRefObject<Set<string>>
  14. pendingCommandRef: React.MutableRefObject<string | null>
  15. firstTextMessageSkipped: React.MutableRefObject<boolean>
  16. }
  17. /**
  18. * Hook to handle messages from the extension.
  19. *
  20. * Processes three types of messages:
  21. * 1. "say" messages - Information from the agent (text, tool output, reasoning)
  22. * 2. "ask" messages - Requests for user input (approvals, followup questions)
  23. * 3. Extension state updates - Mode changes, task history, file search results
  24. *
  25. * Transforms ClineMessage format to TUIMessage format and updates the store.
  26. */
  27. export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
  28. const {
  29. addMessage,
  30. setPendingAsk,
  31. setComplete,
  32. setLoading,
  33. setHasStartedTask,
  34. setFileSearchResults,
  35. setAllSlashCommands,
  36. setAvailableModes,
  37. setCurrentMode,
  38. setTokenUsage,
  39. setRouterModels,
  40. setTaskHistory,
  41. currentTodos,
  42. setTodos,
  43. } = useCLIStore()
  44. // Track seen message timestamps to filter duplicates and the prompt echo
  45. const seenMessageIds = useRef<Set<string>>(new Set())
  46. const firstTextMessageSkipped = useRef(false)
  47. // Track pending command for injecting into command_output toolData
  48. const pendingCommandRef = useRef<string | null>(null)
  49. /**
  50. * Map extension "say" messages to TUI messages
  51. */
  52. const handleSayMessage = useCallback(
  53. (ts: number, say: ClineSay, text: string, partial: boolean) => {
  54. const messageId = ts.toString()
  55. const isResuming = useCLIStore.getState().isResumingTask
  56. if (say === "checkpoint_saved") {
  57. return
  58. }
  59. if (say === "api_req_started") {
  60. return
  61. }
  62. if (say === "user_feedback") {
  63. seenMessageIds.current.add(messageId)
  64. return
  65. }
  66. // Skip first text message ONLY for new tasks, not resumed tasks
  67. // When resuming, we want to show all historical messages including the first one
  68. if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
  69. firstTextMessageSkipped.current = true
  70. seenMessageIds.current.add(messageId)
  71. return
  72. }
  73. if (seenMessageIds.current.has(messageId) && !partial) {
  74. return
  75. }
  76. let role: TUIMessage["role"] = "assistant"
  77. let toolName: string | undefined
  78. let toolDisplayName: string | undefined
  79. let toolDisplayOutput: string | undefined
  80. let toolData: ToolData | undefined
  81. if (say === "command_output") {
  82. role = "tool"
  83. toolName = "execute_command"
  84. toolDisplayName = "bash"
  85. toolDisplayOutput = text
  86. const trackedCommand = pendingCommandRef.current
  87. toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
  88. pendingCommandRef.current = null
  89. } else if (say === "reasoning") {
  90. role = "thinking"
  91. }
  92. seenMessageIds.current.add(messageId)
  93. addMessage({
  94. id: messageId,
  95. role,
  96. content: text || "",
  97. toolName,
  98. toolDisplayName,
  99. toolDisplayOutput,
  100. partial,
  101. originalType: say,
  102. toolData,
  103. })
  104. },
  105. [addMessage],
  106. )
  107. /**
  108. * Handle extension "ask" messages
  109. */
  110. const handleAskMessage = useCallback(
  111. (ts: number, ask: ClineAsk, text: string, partial: boolean) => {
  112. const messageId = ts.toString()
  113. if (partial) {
  114. return
  115. }
  116. if (seenMessageIds.current.has(messageId)) {
  117. return
  118. }
  119. if (ask === "command_output") {
  120. seenMessageIds.current.add(messageId)
  121. return
  122. }
  123. // Handle resume_task and resume_completed_task - stop loading and show text input
  124. // Do not set pendingAsk - just stop loading so user sees normal input to type new message
  125. if (ask === "resume_task" || ask === "resume_completed_task") {
  126. seenMessageIds.current.add(messageId)
  127. setLoading(false)
  128. // Mark that a task has been started so subsequent messages continue the task
  129. // (instead of starting a brand new task via runTask)
  130. setHasStartedTask(true)
  131. // Clear the resuming flag since we're now ready for interaction
  132. // Historical messages should already be displayed from state processing
  133. useCLIStore.getState().setIsResumingTask(false)
  134. // Do not set pendingAsk - let the normal text input appear
  135. return
  136. }
  137. if (ask === "completion_result") {
  138. seenMessageIds.current.add(messageId)
  139. setComplete(true)
  140. setLoading(false)
  141. // Parse the completion result and add a message for CompletionTool to render
  142. try {
  143. const completionInfo = JSON.parse(text) as Record<string, unknown>
  144. const toolData: ToolData = {
  145. tool: "attempt_completion",
  146. result: completionInfo.result as string | undefined,
  147. content: completionInfo.result as string | undefined,
  148. }
  149. addMessage({
  150. id: messageId,
  151. role: "tool",
  152. content: text,
  153. toolName: "attempt_completion",
  154. toolDisplayName: "Task Complete",
  155. toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
  156. originalType: ask,
  157. toolData,
  158. })
  159. } catch {
  160. // If parsing fails, still add a basic completion message
  161. addMessage({
  162. id: messageId,
  163. role: "tool",
  164. content: text || "Task completed",
  165. toolName: "attempt_completion",
  166. toolDisplayName: "Task Complete",
  167. toolDisplayOutput: "✅ Task completed",
  168. originalType: ask,
  169. toolData: {
  170. tool: "attempt_completion",
  171. content: text,
  172. },
  173. })
  174. }
  175. return
  176. }
  177. // Track pending command BEFORE nonInteractive handling
  178. // This ensures we capture the command text for later injection into command_output toolData
  179. if (ask === "command") {
  180. pendingCommandRef.current = text
  181. }
  182. if (nonInteractive && ask !== "followup") {
  183. seenMessageIds.current.add(messageId)
  184. if (ask === "tool") {
  185. let toolName: string | undefined
  186. let toolDisplayName: string | undefined
  187. let toolDisplayOutput: string | undefined
  188. let formattedContent = text || ""
  189. let toolData: ToolData | undefined
  190. let todos: TodoItem[] | undefined
  191. let previousTodos: TodoItem[] | undefined
  192. try {
  193. const toolInfo = JSON.parse(text) as Record<string, unknown>
  194. toolName = toolInfo.tool as string
  195. toolDisplayName = toolInfo.tool as string
  196. toolDisplayOutput = formatToolOutput(toolInfo)
  197. formattedContent = formatToolAskMessage(toolInfo)
  198. // Extract structured toolData for rich rendering
  199. toolData = extractToolData(toolInfo)
  200. // Special handling for update_todo_list tool - extract todos
  201. if (toolName === "update_todo_list" || toolName === "updateTodoList") {
  202. const parsedTodos = parseTodosFromToolInfo(toolInfo)
  203. if (parsedTodos && parsedTodos.length > 0) {
  204. todos = parsedTodos
  205. // Capture previous todos before updating global state
  206. previousTodos = [...currentTodos]
  207. setTodos(parsedTodos)
  208. }
  209. }
  210. } catch {
  211. // Use raw text if not valid JSON
  212. }
  213. addMessage({
  214. id: messageId,
  215. role: "tool",
  216. content: formattedContent,
  217. toolName,
  218. toolDisplayName,
  219. toolDisplayOutput,
  220. originalType: ask,
  221. toolData,
  222. todos,
  223. previousTodos,
  224. })
  225. } else {
  226. addMessage({
  227. id: messageId,
  228. role: "assistant",
  229. content: text || "",
  230. originalType: ask,
  231. })
  232. }
  233. return
  234. }
  235. let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
  236. let questionText = text
  237. if (ask === "followup") {
  238. try {
  239. const data = JSON.parse(text)
  240. questionText = data.question || text
  241. suggestions = Array.isArray(data.suggest) ? data.suggest : undefined
  242. } catch {
  243. // Use raw text
  244. }
  245. } else if (ask === "tool") {
  246. try {
  247. const toolInfo = JSON.parse(text) as Record<string, unknown>
  248. questionText = formatToolAskMessage(toolInfo)
  249. } catch {
  250. // Use raw text if not valid JSON
  251. }
  252. }
  253. // Note: ask === "command" is handled above before the nonInteractive block
  254. seenMessageIds.current.add(messageId)
  255. setPendingAsk({
  256. id: messageId,
  257. type: ask,
  258. content: questionText,
  259. suggestions,
  260. })
  261. },
  262. [addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos],
  263. )
  264. /**
  265. * Handle all extension messages
  266. */
  267. const handleExtensionMessage = useCallback(
  268. (msg: ExtensionMessage) => {
  269. if (msg.type === "state") {
  270. const state = msg.state
  271. if (!state) {
  272. return
  273. }
  274. // Extract and update current mode from state
  275. const newMode = state.mode
  276. if (newMode) {
  277. setCurrentMode(newMode)
  278. }
  279. // Extract and update task history from state
  280. const newTaskHistory = state.taskHistory
  281. if (newTaskHistory && Array.isArray(newTaskHistory)) {
  282. setTaskHistory(newTaskHistory)
  283. }
  284. const clineMessages = state.clineMessages
  285. if (clineMessages) {
  286. for (const clineMsg of clineMessages) {
  287. const ts = clineMsg.ts
  288. const type = clineMsg.type
  289. const say = clineMsg.say
  290. const ask = clineMsg.ask
  291. const text = clineMsg.text || ""
  292. const partial = clineMsg.partial || false
  293. if (type === "say" && say) {
  294. handleSayMessage(ts, say, text, partial)
  295. } else if (type === "ask" && ask) {
  296. handleAskMessage(ts, ask, text, partial)
  297. }
  298. }
  299. // Compute token usage metrics from clineMessages
  300. // Skip first message (task prompt) as per webview UI pattern
  301. if (clineMessages.length > 1) {
  302. const processed = consolidateApiRequests(
  303. consolidateCommands(clineMessages.slice(1) as ClineMessage[]),
  304. )
  305. const metrics = consolidateTokenUsage(processed)
  306. setTokenUsage(metrics)
  307. }
  308. }
  309. // After processing state, clear the resuming flag if it was set
  310. // This ensures the flag is cleared even if no resume_task ask message is received
  311. if (useCLIStore.getState().isResumingTask) {
  312. useCLIStore.getState().setIsResumingTask(false)
  313. }
  314. } else if (msg.type === "messageUpdated") {
  315. const clineMessage = msg.clineMessage
  316. if (!clineMessage) {
  317. return
  318. }
  319. const ts = clineMessage.ts
  320. const type = clineMessage.type
  321. const say = clineMessage.say
  322. const ask = clineMessage.ask
  323. const text = clineMessage.text || ""
  324. const partial = clineMessage.partial || false
  325. if (type === "say" && say) {
  326. handleSayMessage(ts, say, text, partial)
  327. } else if (type === "ask" && ask) {
  328. handleAskMessage(ts, ask, text, partial)
  329. }
  330. } else if (msg.type === "fileSearchResults") {
  331. setFileSearchResults((msg.results as FileResult[]) || [])
  332. } else if (msg.type === "commands") {
  333. setAllSlashCommands((msg.commands as SlashCommandResult[]) || [])
  334. } else if (msg.type === "modes") {
  335. setAvailableModes((msg.modes as ModeResult[]) || [])
  336. } else if (msg.type === "routerModels") {
  337. if (msg.routerModels) {
  338. setRouterModels(msg.routerModels)
  339. }
  340. }
  341. },
  342. [
  343. handleSayMessage,
  344. handleAskMessage,
  345. setFileSearchResults,
  346. setAllSlashCommands,
  347. setAvailableModes,
  348. setCurrentMode,
  349. setTokenUsage,
  350. setRouterModels,
  351. setTaskHistory,
  352. ],
  353. )
  354. return {
  355. handleExtensionMessage,
  356. seenMessageIds,
  357. pendingCommandRef,
  358. firstTextMessageSkipped,
  359. }
  360. }