Hooks UI improvements

This commit is contained in:
Eve Killaby
2025-10-22 15:49:51 -07:00
parent 0a88976b44
commit ead7727830
12 changed files with 819 additions and 240 deletions
+2
View File
@@ -12,6 +12,8 @@ service TaskService {
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Cancels the currently running hook execution
rpc cancelHookExecution(EmptyRequest) returns (Boolean);
// Clears the current task
rpc clearTask(EmptyRequest) returns (Empty);
// Gets the total size of all tasks
+7
View File
@@ -457,6 +457,13 @@ export class Controller {
}
}
async cancelHookExecution(): Promise<boolean> {
if (!this.task) {
return false
}
return await this.task.cancelHookExecution()
}
/**
* Check if we should show the background terminal suggestion based on shell integration warning frequency
* @returns true if we should show the suggestion, false otherwise
@@ -0,0 +1,13 @@
import { Boolean, EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Cancels the currently running hook execution
* @param controller The controller instance
* @param _request Empty request (no parameters needed)
* @returns Boolean indicating whether a hook was successfully cancelled
*/
export async function cancelHookExecution(controller: Controller, _request: EmptyRequest): Promise<Boolean> {
const success = await controller.cancelHookExecution()
return Boolean.create({ value: success })
}
+28
View File
@@ -30,6 +30,7 @@ export class HookProcess extends EventEmitter {
constructor(
private readonly scriptPath: string,
private readonly timeoutMs: number = 30000,
private readonly abortSignal?: AbortSignal,
) {
super()
}
@@ -40,6 +41,24 @@ export class HookProcess extends EventEmitter {
*/
async run(inputJson: string): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already aborted
if (this.abortSignal?.aborted) {
reject(new Error("Hook execution cancelled"))
return
}
// Set up abort handler
const abortHandler = () => {
if (this.childProcess && !this.isCompleted) {
this.childProcess.kill("SIGTERM")
reject(new Error("Hook execution cancelled by user"))
}
}
if (this.abortSignal) {
this.abortSignal.addEventListener("abort", abortHandler)
}
// Spawn the hook process
this.childProcess = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
@@ -98,6 +117,11 @@ export class HookProcess extends EventEmitter {
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("completed", code, signal)
if (code === 0) {
@@ -113,6 +137,10 @@ export class HookProcess extends EventEmitter {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("error", error)
reject(error)
})
+51 -38
View File
@@ -134,16 +134,22 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
hookName: Name,
public readonly scriptPath: string,
private readonly streamCallback?: HookStreamCallback,
private readonly abortSignal?: AbortSignal,
) {
super(hookName)
}
override async [exec](input: HookInput): Promise<HookOutput> {
// Check if already aborted before starting
if (this.abortSignal?.aborted) {
throw new Error("Hook execution cancelled before start")
}
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// Create HookProcess for execution with streaming
const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS)
const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, this.abortSignal)
// Set up streaming if callback is provided
if (this.streamCallback) {
@@ -162,9 +168,8 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Hook status is determined by exit code
if (exitCode === 0) {
// Hook succeeded - try to parse JSON output
// Try to parse JSON output
const parseJsonOutput = (): HookOutput | null => {
try {
const outputData = JSON.parse(stdout)
const output = HookOutput.fromJSON(outputData)
@@ -182,7 +187,6 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
return output
} catch (parseError) {
// JSON parsing failed, but hook succeeded (exit code 0)
// Try to extract JSON from stdout (it might have debug output before/after)
const jsonMatch = stdout.match(/\{[\s\S]*\}/)
if (jsonMatch) {
@@ -203,49 +207,57 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
return output
} catch (extractError) {
// Could not extract valid JSON, but hook succeeded
// Append JSON parsing error to stderr for display
if (this.streamCallback) {
this.streamCallback(
`\n⚠️ Warning: Hook completed successfully but JSON response could not be parsed.`,
"stderr",
)
this.streamCallback(` No context will be added to the conversation.`, "stderr")
this.streamCallback(
` Error: ${extractError instanceof Error ? extractError.message : String(extractError)}`,
"stderr",
)
}
return HookOutput.create({
shouldContinue: true,
})
return null
}
} else {
// No JSON found in output, but hook succeeded
if (this.streamCallback) {
this.streamCallback(
`\n⚠️ Warning: Hook completed successfully but no JSON response found in output.`,
"stderr",
)
this.streamCallback(` No context will be added to the conversation.`, "stderr")
}
return HookOutput.create({
shouldContinue: true,
})
}
return null
}
}
const parsedOutput = parseJsonOutput()
// If we have valid JSON, honor it regardless of exit code
if (parsedOutput) {
if (exitCode !== 0 && this.streamCallback) {
// Log that hook exited non-zero but we're using the JSON
this.streamCallback(
`\n⚠️ Note: Hook exited with code ${exitCode} but provided valid JSON response.`,
"stderr",
)
if (stderr) {
this.streamCallback(` stderr: ${stderr}`, "stderr")
}
}
return parsedOutput
}
// No valid JSON found
if (exitCode === 0) {
// Hook succeeded but didn't provide JSON
if (this.streamCallback) {
this.streamCallback(
`\n⚠️ Warning: Hook completed successfully but no JSON response found in output.`,
"stderr",
)
this.streamCallback(` No context will be added to the conversation.`, "stderr")
}
return HookOutput.create({
shouldContinue: true,
})
} else {
// Hook failed with non-zero exit code
// Hook failed - throw error so UI shows "Failed" status
const errorDetails = stderr ? `. stderr: ${stderr}` : ""
throw new Error(`Hook exited with code ${exitCode}${errorDetails}`)
}
} catch (error) {
// Hook execution failed
// Hook execution failed (timeout, cancellation, or fatal error)
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Re-throw the error so ToolExecutor sees "Failed" status in UI
// ToolExecutor will catch this and decide whether to block tool execution
// (Only shouldContinue: false blocks execution)
if (error instanceof Error) {
// Include stderr in error message if available
const errorDetails = stderr ? `. stderr: ${stderr}` : ""
throw new Error(`${error.message}${errorDetails}`)
}
@@ -343,14 +355,15 @@ export class HookFactory {
}
/**
* Create a hook runner with optional streaming callback support
* Create a hook runner with optional streaming callback and abort signal support
*/
async createWithStreaming<Name extends HookName>(
hookName: Name,
streamCallback?: HookStreamCallback,
abortSignal?: AbortSignal,
): Promise<HookRunner<Name>> {
const scripts = await HookFactory.findHookScripts(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback))
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback, abortSignal))
if (runners.length === 0) {
return new NoOpRunner(hookName)
}
+8
View File
@@ -62,6 +62,14 @@ export class TaskState {
didFinishAbortingStream = false
abandoned = false
// Hook execution tracking for cancellation
activeHookExecution?: {
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
// Auto-context summarization
currentlySummarizing: boolean = false
lastAutoCompactTriggerIndex?: number
+151 -20
View File
@@ -412,6 +412,7 @@ export class ToolExecutor {
let executionSuccess = true
let toolResult: any = null
let pendingToolTs: number | undefined
// Run PreToolUse hook, if enabled
if (hooksEnabled) {
@@ -421,22 +422,76 @@ export class ToolExecutor {
if (hasPreToolUseHook) {
let preToolUseResult: any = null
let hookMessageTs: number | undefined
const abortController = new AbortController()
try {
// Show hook execution indicator and capture timestamp
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
// Include a preview of content (first 200 chars)
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
// Include a preview of diff (first 200 chars)
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
// Show hook execution indicator with pending tool info
const hookMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: "running",
pendingToolInfo, // Include tool info in hook message
}
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
// Track active hook execution for cancellation (only if message was created)
if (hookMessageTs !== undefined) {
this.taskState.activeHookExecution = {
hookName: "PreToolUse",
toolName: block.name,
messageTs: hookMessageTs,
abortController,
}
}
// Create streaming callback that displays hook output in real-time
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
// Display the output line in the UI
await this.say("hook_output", line)
}
const preToolUseHook = await hookFactory.createWithStreaming("PreToolUse", streamCallback)
const preToolUseHook = await hookFactory.createWithStreaming(
"PreToolUse",
streamCallback,
abortController.signal,
)
preToolUseResult = await preToolUseHook.run({
taskId: this.taskId,
@@ -446,6 +501,9 @@ export class ToolExecutor {
},
})
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Update hook status to completed (update the same message)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
@@ -466,8 +524,27 @@ export class ToolExecutor {
// Check if hook wants to stop execution
if (!preToolUseResult.shouldContinue) {
const errorMessage = preToolUseResult.errorMessage || "PreToolUse hook prevented tool execution"
await this.say("error", errorMessage)
// Update hook status to show shouldContinue: false in the UI
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const blockedMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: "completed",
exitCode: 0,
hasJsonResponse: true,
shouldContinue: false,
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(blockedMetadata),
})
}
}
// Block execution with the hook's error message (or default)
const errorMessage = preToolUseResult.errorMessage || "Hook prevented tool execution"
this.pushToolResult(formatResponse.toolError(errorMessage), block)
return
}
@@ -475,7 +552,22 @@ export class ToolExecutor {
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse")
} catch (hookError) {
// Update hook status to failed (update the same message if it exists)
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Check if this was a cancellation
const wasCancelled = hookError instanceof Error && hookError.message.includes("cancelled")
// Extract exit code from error message if present
let exitCode: number | undefined
if (hookError instanceof Error) {
const exitCodeMatch = hookError.message.match(/exited with code (\d+)/)
if (exitCodeMatch) {
exitCode = parseInt(exitCodeMatch[1], 10)
}
}
// Update hook status to failed or cancelled (update the same message if it exists)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
@@ -483,8 +575,8 @@ export class ToolExecutor {
const failedMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: "failed",
exitCode: hookError instanceof Error ? 1 : undefined,
status: wasCancelled ? "cancelled" : "failed",
exitCode: wasCancelled ? 130 : (exitCode ?? 1),
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(failedMetadata),
@@ -492,10 +584,13 @@ export class ToolExecutor {
}
}
const errorMessage = `PreToolUse hook failed: ${hookError.toString()}`
await this.say("error", errorMessage)
this.pushToolResult(formatResponse.toolError(errorMessage), block)
return
// Hook errors never block tool execution (fail-open)
// Only explicit shouldContinue: false in JSON blocks execution
const errorMessage = wasCancelled
? "Hook cancelled by user"
: `PreToolUse hook failed: ${hookError.toString()}`
await this.say("hook_output", `\n${errorMessage} - continuing with tool execution`)
// Don't return - continue to tool execution below
}
}
}
@@ -518,6 +613,8 @@ export class ToolExecutor {
if (hasPostToolUseHook) {
let hookMessageTs: number | undefined
const abortController = new AbortController()
try {
// Show hook execution indicator and capture timestamp
const hookMetadata = {
@@ -527,12 +624,26 @@ export class ToolExecutor {
}
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
// Track active hook execution for cancellation (only if message was created)
if (hookMessageTs !== undefined) {
this.taskState.activeHookExecution = {
hookName: "PostToolUse",
toolName: block.name,
messageTs: hookMessageTs,
abortController,
}
}
// Create streaming callback that displays hook output in real-time
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
await this.say("hook_output", line)
}
const postToolUseHook = await hookFactory.createWithStreaming("PostToolUse", streamCallback)
const postToolUseHook = await hookFactory.createWithStreaming(
"PostToolUse",
streamCallback,
abortController.signal,
)
const executionTimeMs = Date.now() - executionStartTime
const postToolUseResult = await postToolUseHook.run({
@@ -567,12 +678,30 @@ export class ToolExecutor {
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
// Log any error messages from the hook
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Log any error messages from the hook (don't display as UI error)
if (postToolUseResult.errorMessage) {
this.say("error", postToolUseResult.errorMessage)
console.error(`[PostToolUse Hook] ${postToolUseResult.errorMessage}`)
}
} catch (hookError) {
// Update hook status to failed (update the same message if it exists)
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Check if this was a cancellation
const wasCancelled = hookError instanceof Error && hookError.message.includes("cancelled")
// Extract exit code from error message if present
let exitCode: number | undefined
if (hookError instanceof Error) {
const exitCodeMatch = hookError.message.match(/exited with code (\d+)/)
if (exitCodeMatch) {
exitCode = parseInt(exitCodeMatch[1], 10)
}
}
// Update hook status to failed or cancelled (update the same message if it exists)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
@@ -580,8 +709,8 @@ export class ToolExecutor {
const failedMetadata = {
hookName: "PostToolUse",
toolName: block.name,
status: "failed",
exitCode: hookError instanceof Error ? 1 : undefined,
status: wasCancelled ? "cancelled" : "failed",
exitCode: wasCancelled ? 130 : (exitCode ?? 1),
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(failedMetadata),
@@ -589,9 +718,11 @@ export class ToolExecutor {
}
}
// PostToolUse hook failure is non-fatal, just log it
const errorMessage = `PostToolUse hook failed: ${hookError.toString()}`
await this.say("error", errorMessage)
// PostToolUse hook failure is non-fatal, just log it (don't display in UI)
const errorMessage = wasCancelled
? "PostToolUse hook cancelled by user"
: `PostToolUse hook failed: ${hookError.toString()}`
console.error(`[PostToolUse Hook] ${errorMessage}`)
}
}
}
+50
View File
@@ -1238,6 +1238,13 @@ export class Task {
async abortTask() {
try {
// Cancel any running hook execution first
try {
await this.cancelHookExecution()
} catch (error) {
Logger.error("Failed to cancel hook during task abort", error)
}
// Run TaskCancel hook
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled) {
@@ -1785,6 +1792,49 @@ export class Task {
return true
}
/**
* Cancel a currently running hook execution
* @returns true if a hook was cancelled, false if no hook was running
*/
public async cancelHookExecution(): Promise<boolean> {
if (!this.taskState.activeHookExecution) {
return false
}
const { hookName, toolName, messageTs, abortController } = this.taskState.activeHookExecution
try {
// Signal cancellation to abort the hook process
abortController.abort()
// Clear active hook execution state
this.taskState.activeHookExecution = undefined
// Update hook message status to "cancelled"
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
if (hookMessageIndex !== -1) {
const cancelledMetadata = {
hookName,
toolName,
status: "cancelled",
exitCode: 130, // Standard SIGTERM exit code
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(cancelledMetadata),
})
}
// Notify UI that hook was cancelled
await this.say("hook_output", "\nHook execution cancelled by user")
return true
} catch (error) {
Logger.error("Failed to cancel hook execution", error)
return false
}
}
/**
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
*/
+12
View File
@@ -139,4 +139,16 @@ export class MessageStateHandler {
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
}
async deleteClineMessage(index: number): Promise<void> {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Remove the message at the specified index
this.clineMessages.splice(index, 1)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
}
}
+9 -1
View File
@@ -192,9 +192,17 @@ export interface ClineSayTool {
export interface ClineSayHook {
hookName: string // Name of the hook (e.g., "PreToolUse", "PostToolUse")
toolName?: string // Tool name if applicable (for PreToolUse/PostToolUse)
status: "running" | "completed" | "failed" // Execution status
status: "running" | "completed" | "failed" | "cancelled" // Execution status
exitCode?: number // Exit code when completed
hasJsonResponse?: boolean // Whether a JSON response was parsed
shouldContinue?: boolean // Whether hook allowed tool execution to proceed (false = blocked)
// Pending tool information (only present during PreToolUse "running" status)
pendingToolInfo?: {
tool: string // Tool name (e.g., "write_to_file", "execute_command")
path?: string // File path for file operations
command?: string // Command for execute_command
content?: string // Content preview (first 200 chars)
}
}
// must keep in sync with system prompt
+294 -176
View File
@@ -1,211 +1,329 @@
import { ClineMessage } from "./ExtensionMessage"
/**
* Combines sequences of hook and hook_output messages in an array of ClineMessages,
* and reorders PreToolUse hooks to appear before their associated tool messages.
*
* This function:
* 1. Combines 'hook' messages with their following 'hook_output' messages
* 2. Reorders PreToolUse hooks to appear BEFORE their associated tool messages
* 3. Keeps PostToolUse hooks AFTER their associated tool messages
*
* @param messages - An array of ClineMessage objects to process.
* @returns A new array of ClineMessage objects with hook sequences combined and reordered.
* Hook metadata extracted from hook message text.
* Mirrors the ClineSayHook interface but represents parsed data.
*/
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Filter out partial tool/command messages to prevent duplicates during React render cycles
// (partial messages are removed and replaced with complete versions, but may briefly coexist)
// IMPORTANT: Only filter tool/command messages, NOT reasoning or other message types
const filteredMessages = messages.filter((msg) => {
// NEVER filter reasoning messages, even if they have partial: true
interface HookMetadata {
hookName: string // e.g., "PreToolUse", "PostToolUse"
toolName?: string
status?: "running" | "completed" | "failed" | "cancelled"
exitCode?: number
hasJsonResponse?: boolean
shouldContinue?: boolean
}
// ============================================================================
// PART 1: TYPE GUARDS & UTILITIES
// ============================================================================
/**
* Type guard to check if a message is a tool or command.
*/
function isToolOrCommandMessage(msg: ClineMessage): boolean {
return msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
}
/**
* Safely parses hook metadata from a hook message.
* Returns null if parsing fails or message is not a hook.
*/
function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
if (hookMessage.say !== "hook" || !hookMessage.text) {
return null
}
try {
const outputIndex = hookMessage.text.indexOf(HOOK_OUTPUT_STRING)
const metadataStr = outputIndex !== -1 ? hookMessage.text.slice(0, outputIndex).trim() : hookMessage.text.trim()
return JSON.parse(metadataStr) as HookMetadata
} catch {
return null
}
}
// ============================================================================
// PART 2: FILTERING & COMBINING
// ============================================================================
/**
* Filters out partial tool/command messages while preserving all other types.
* Reasoning messages are always kept, even if marked partial.
*
* This prevents duplicate messages during React render cycles where partial
* messages are removed and replaced with complete versions.
*/
function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
return messages.filter((msg) => {
// Always keep reasoning messages
if (msg.say === "reasoning") {
return true // Always keep reasoning messages
return true
}
// Only check tool and command messages for partial filtering
const isToolMessage = msg.ask === "tool" || msg.say === "tool"
const isCommandMessage = msg.ask === "command" || msg.say === "command"
const isToolOrCommand = isToolMessage || isCommandMessage
// Keep all messages EXCEPT partial tool/command messages
// This preserves: reasoning (explicitly checked above), text, hooks, and complete tool/command messages
if (isToolOrCommand && msg.partial === true) {
return false // Filter out partial tool/command messages
}
return true // Keep everything else
// Filter out partial tool/command messages only
const isToolOrCommand = isToolOrCommandMessage(msg)
return !(isToolOrCommand && msg.partial === true)
})
}
const combinedHooks: ClineMessage[] = []
/**
* Combines a single hook message with all subsequent hook_output messages.
*
* @param hookMessage The hook message to start combining from
* @param startIndex The index of the hook message in the messages array
* @param messages The full messages array
* @returns Object containing the combined message and the next index to process
*/
function combineHookWithOutputs(
hookMessage: ClineMessage,
startIndex: number,
messages: ClineMessage[],
): { combined: ClineMessage; nextIndex: number } {
let combinedText = hookMessage.text || ""
let hasOutput = false
let i = startIndex + 1
// First pass: combine hooks with their outputs (using filtered messages)
for (let i = 0; i < filteredMessages.length; i++) {
if (filteredMessages[i].say === "hook") {
let combinedText = filteredMessages[i].text || ""
let didAddOutput = false
let j = i + 1
while (j < filteredMessages.length) {
if (filteredMessages[j].say === "hook") {
// Stop if we encounter the next hook
break
}
if (filteredMessages[j].say === "hook_output") {
if (!didAddOutput) {
// Add a marker before the first output
combinedText += `\n${HOOK_OUTPUT_STRING}`
didAddOutput = true
}
// Handle cases where we receive empty hook_output
const output = filteredMessages[j].text || ""
if (output.length > 0) {
combinedText += "\n" + output
}
}
j++
// Collect all hook_output messages until we hit another hook or end of array
while (i < messages.length && messages[i].say !== "hook") {
if (messages[i].say === "hook_output") {
// Add marker before first output
if (!hasOutput) {
combinedText += `\n${HOOK_OUTPUT_STRING}`
hasOutput = true
}
combinedHooks.push({
...filteredMessages[i],
text: combinedText,
})
// Append output if not empty
const output = messages[i].text || ""
if (output.length > 0) {
combinedText += "\n" + output
}
}
i++
}
i = j - 1 // Move to the index just before the next hook or end of array
return {
combined: { ...hookMessage, text: combinedText },
nextIndex: i,
}
}
/**
* Combines all hooks with their outputs and removes hook_output messages.
*
* This is a two-pass process:
* 1. Scan through and combine each hook with its outputs
* 2. Build final array without hook_output messages, using combined hooks
*/
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
// Pass 1: Build map of combined hooks by timestamp
const combinedHooksByTs = new Map<number, ClineMessage>()
for (let i = 0; i < messages.length; i++) {
if (messages[i].say === "hook") {
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
combinedHooksByTs.set(combined.ts, combined)
i = nextIndex - 1 // Adjust for loop increment
}
}
// Second pass: remove hook_outputs and replace original hooks with combined ones (using filtered messages)
const processedMessages = filteredMessages
.filter((msg) => msg.say !== "hook_output")
.map((msg) => {
if (msg.say === "hook") {
const combinedHook = combinedHooks.find((hook) => hook.ts === msg.ts)
return combinedHook || msg
}
return msg
})
// Pass 2: Build result array
const result: ClineMessage[] = []
// Third pass: reorder PreToolUse hooks to appear before their associated tool/command messages
// Build a map of tool timestamps to their PreToolUse hooks
const preToolUseHooksByNextTool = new Map<number, ClineMessage[]>()
// First scan: identify PreToolUse hooks and map them to the next tool/command
// IMPORTANT: We look for tools in the ORIGINAL messages array to match hooks immediately,
// even if the tool is still partial. This prevents delays in showing hooks.
for (let i = 0; i < processedMessages.length; i++) {
const msg = processedMessages[i]
if (msg.say === "hook") {
try {
const outputIndex = msg.text?.indexOf(HOOK_OUTPUT_STRING) ?? -1
const metadataStr = outputIndex !== -1 ? msg.text?.slice(0, outputIndex).trim() : msg.text?.trim()
const metadata = JSON.parse(metadataStr || "{}")
if (metadata.hookName === "PreToolUse") {
// Find the corresponding tool in the ORIGINAL messages array (not filtered)
// Look backwards from the hook's position in the original array
const hookIndexInOriginal = messages.findIndex((m) => m.ts === msg.ts)
for (let j = hookIndexInOriginal - 1; j >= 0; j--) {
const prevMsg = messages[j]
const isToolOrCommand =
prevMsg.ask === "tool" ||
prevMsg.say === "tool" ||
prevMsg.ask === "command" ||
prevMsg.say === "command"
if (isToolOrCommand) {
// Map this hook to appear before this tool
// Use the tool's timestamp even if it's still partial
if (!preToolUseHooksByNextTool.has(prevMsg.ts)) {
preToolUseHooksByNextTool.set(prevMsg.ts, [])
}
preToolUseHooksByNextTool.get(prevMsg.ts)!.push(msg)
break
}
}
}
} catch (e) {
// If parsing fails, continue
}
for (const msg of messages) {
if (msg.say === "hook_output") {
} else if (msg.say === "hook") {
// Use combined version
result.push(combinedHooksByTs.get(msg.ts) || msg)
} else {
// Keep all other messages as-is
result.push(msg)
}
}
// Second scan: build the reordered array
const reorderedMessages: ClineMessage[] = []
const processedHookTimestamps = new Set<number>()
const processedToolTimestamps = new Set<number>()
return result
}
// Find which tool timestamps actually exist in processedMessages
const availableToolTimestamps = new Set<number>()
for (const msg of processedMessages) {
const isToolOrCommand = msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
if (isToolOrCommand) {
availableToolTimestamps.add(msg.ts)
// ============================================================================
// PART 3: PRETOOLUSE REORDERING
// ============================================================================
/**
* Finds the timestamp of the next tool/command after a given index.
*
* Searches in the original messages array (not filtered) to catch tools
* that might still be partial. This ensures PreToolUse hooks are matched
* immediately even if their tool hasn't fully arrived yet.
*
* @param hookIndex The starting index to search from
* @param messages The original messages array (may include partial tools)
* @returns The timestamp of the next tool, or null if none found
*/
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
for (let i = hookIndex + 1; i < messages.length; i++) {
if (isToolOrCommandMessage(messages[i])) {
return messages[i].ts
}
}
return null
}
/**
* Builds a map of tool timestamps to their PreToolUse hooks.
*
* This map indicates which hooks should be moved to appear before which tools.
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
*
* @param processedMessages Messages after filtering and combining
* @param originalMessages Original messages array (used to find tools)
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
*/
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
const map = new Map<number, ClineMessage[]>()
// Build timestamp-to-index map once to avoid O(n) findIndex calls
const timestampToIndex = new Map<number, number>()
for (let i = 0; i < originalMessages.length; i++) {
timestampToIndex.set(originalMessages[i].ts, i)
}
for (const msg of processedMessages) {
// Check if this tool/command has PreToolUse hooks that should appear before it
const hooksForThisTool = preToolUseHooksByNextTool.get(msg.ts)
if (hooksForThisTool && hooksForThisTool.length > 0) {
// Only insert hooks that haven't been added yet
const hooksToAdd = hooksForThisTool.filter((hook) => !processedHookTimestamps.has(hook.ts))
if (hooksToAdd.length > 0) {
// Insert hooks before the tool
reorderedMessages.push(...hooksToAdd)
// Mark these hooks as processed
hooksToAdd.forEach((hook) => processedHookTimestamps.add(hook.ts))
}
// Mark this tool as having been processed
processedToolTimestamps.add(msg.ts)
// Add the tool immediately after its hooks
reorderedMessages.push(msg)
continue // Skip the default add at the end
}
// Check if this tool was already added with its hooks
if (processedToolTimestamps.has(msg.ts)) {
// Skip this tool, it's already been added with its PreToolUse hooks
// Only process PreToolUse hooks
const metadata = parseHookMetadata(msg)
if (metadata?.hookName !== "PreToolUse") {
continue
}
// Check if this is a PreToolUse hook that will be moved before its tool
if (msg.say === "hook") {
try {
const outputIndex = msg.text?.indexOf(HOOK_OUTPUT_STRING) ?? -1
const metadataStr = outputIndex !== -1 ? msg.text?.slice(0, outputIndex).trim() : msg.text?.trim()
const metadata = JSON.parse(metadataStr || "{}")
if (metadata.hookName === "PreToolUse") {
// Find which tool (if any) this hook is mapped to
let matchedToolTimestamp: number | undefined
for (const [toolTs, hooks] of preToolUseHooksByNextTool.entries()) {
if (hooks.some((h) => h.ts === msg.ts)) {
matchedToolTimestamp = toolTs
break
}
}
// Only skip this hook if its tool is present AND we'll process it before the tool
if (matchedToolTimestamp !== undefined && availableToolTimestamps.has(matchedToolTimestamp)) {
// Skip - already inserted before its tool
continue
}
// Otherwise fall through to add in normal position
}
} catch (e) {
// If parsing fails, fall through to normal processing
}
// Find this hook's position in the original array using the index map
const hookIndexInOriginal = timestampToIndex.get(msg.ts)
if (hookIndexInOriginal === undefined) {
continue // Shouldn't happen, but be safe
}
// Add the message in its normal position
// This includes: PreToolUse hooks whose tools aren't available yet, PostToolUse hooks, reasoning, text, etc.
reorderedMessages.push(msg)
// Find the next tool after this hook in the original array
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
if (toolTimestamp === null) {
// No tool found - hook will stay in original position
continue
}
// Map this hook to appear before that tool
if (!map.has(toolTimestamp)) {
map.set(toolTimestamp, [])
}
map.get(toolTimestamp)!.push(msg)
}
return reorderedMessages
return map
}
/**
* Reorders messages so PreToolUse hooks appear before their associated tools.
*
* Algorithm:
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
* 2. If yes, insert those hooks BEFORE the tool
* 3. Track which hooks and tools we've already added to avoid duplicates
* 4. For PreToolUse hooks encountered in their original position:
* - If their tool is available and we'll process them before it, skip them
* - Otherwise, add them in their current position (tool not available yet)
*
* @param messages Messages after filtering and combining
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
* @returns Reordered messages array
*/
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
const result: ClineMessage[] = []
const addedHooks = new Set<number>()
const addedTools = new Set<number>()
// Build set of available tool timestamps for quick lookup
const availableTools = new Set<number>()
for (const msg of messages) {
if (isToolOrCommandMessage(msg)) {
availableTools.add(msg.ts)
}
}
for (const msg of messages) {
// Case 1: This is a tool with PreToolUse hooks
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
const hooksForTool = preToolUseMap.get(msg.ts)!
// Insert hooks that haven't been added yet
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
result.push(...newHooks)
newHooks.forEach((h) => addedHooks.add(h.ts))
// Add the tool
result.push(msg)
addedTools.add(msg.ts)
continue
}
// Case 2: This tool was already added with its hooks
if (addedTools.has(msg.ts)) {
continue
}
// Case 3: This is a PreToolUse hook in its original position
const metadata = parseHookMetadata(msg)
if (metadata?.hookName === "PreToolUse") {
// Find which tool (if any) this hook is mapped to
let mappedToolTs: number | undefined
for (const [toolTs, hooks] of preToolUseMap) {
if (hooks.some((h) => h.ts === msg.ts)) {
mappedToolTs = toolTs
break
}
}
// If this hook's tool is available and we'll insert it before that tool, skip it here
if (mappedToolTs !== undefined && availableTools.has(mappedToolTs)) {
continue
}
// Otherwise, keep hook in original position (tool not available yet)
}
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
result.push(msg)
}
return result
}
// ============================================================================
// MAIN FUNCTION
// ============================================================================
/**
* Combines sequences of hook and hook_output messages, and reorders
* PreToolUse hooks to appear before their associated tool messages.
*
* Process:
* 1. Filter out partial tool/command messages (React render cycle cleanup)
* 2. Combine hooks with their hook_output messages
* 3. Build mapping of tools to their PreToolUse hooks
* 4. Reorder so PreToolUse hooks appear before their tools
*
* @param messages Array of ClineMessage objects to process
* @returns New array with hooks combined and PreToolUse hooks reordered
*/
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Phase 1: Filter out partial tool/command messages
const filtered = filterPartialToolMessages(messages)
// Phase 2: Combine hooks with their outputs
const combined = combineAllHooks(filtered)
// Phase 3: Build PreToolUse hook mapping
const preToolUseMap = buildPreToolUseMap(combined, messages)
// Phase 4: Reorder to place PreToolUse hooks before tools
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
return reordered
}
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
+194 -5
View File
@@ -1677,6 +1677,19 @@ export const ChatRowContent = memo(
status: string
exitCode?: number
hasJsonResponse?: boolean
shouldContinue?: boolean
pendingToolInfo?: {
tool: string
path?: string
command?: string
content?: string
diff?: string
regex?: string
url?: string
mcpTool?: string
mcpServer?: string
resourceUri?: string
}
}
try {
hookMetadata = JSON.parse(metadataStr)
@@ -1688,6 +1701,9 @@ export const ChatRowContent = memo(
const isRunning = hookMetadata?.status === "running"
const isCompleted = hookMetadata?.status === "completed"
const isFailed = hookMetadata?.status === "failed"
const isCancelled = hookMetadata?.status === "cancelled"
const pendingToolInfo = hookMetadata?.pendingToolInfo
const cancelledColor = "var(--vscode-descriptionForeground)"
return (
<>
@@ -1722,7 +1738,9 @@ export const ChatRowContent = memo(
padding: "8px 10px",
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
borderBottom:
output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
pendingToolInfo || output.length > 0
? "1px solid var(--vscode-editorGroup-border)"
: "none",
borderTopLeftRadius: "6px",
borderTopRightRadius: "6px",
}}>
@@ -1750,7 +1768,13 @@ export const ChatRowContent = memo(
/>
<span
style={{
color: isRunning ? successColor : isFailed ? errorColor : successColor,
color: isRunning
? successColor
: isFailed
? errorColor
: isCancelled
? cancelledColor
: successColor,
fontWeight: 500,
fontSize: "13px",
flexShrink: 0,
@@ -1759,9 +1783,11 @@ export const ChatRowContent = memo(
? "Running"
: isFailed
? "Failed"
: isCompleted
? "Completed"
: "Unknown"}
: isCancelled
? "Cancelled"
: isCompleted
? "Completed"
: "Unknown"}
</span>
{hookMetadata.exitCode !== undefined && hookMetadata.exitCode !== 0 && (
<span
@@ -1772,8 +1798,171 @@ export const ChatRowContent = memo(
(exit: {hookMetadata.exitCode})
</span>
)}
{hookMetadata.shouldContinue === false && (
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
(shouldContinue: false)
</span>
)}
</div>
{isRunning && (
<button
onClick={(e) => {
e.stopPropagation()
TaskServiceClient.cancelHookExecution({}).catch((err) =>
console.error("Failed to cancel hook:", err),
)
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--vscode-button-secondaryHoverBackground)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--vscode-button-secondaryBackground)"
}}
style={{
background: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
border: "none",
borderRadius: "2px",
padding: "4px 10px",
fontSize: "12px",
cursor: "pointer",
fontFamily: "inherit",
}}>
cancel
</button>
)}
</div>
{/* Show pending tool info when hook is running */}
{isRunning && pendingToolInfo && (
<div
style={{
padding: "12px",
backgroundColor: "var(--vscode-editor-background)",
borderBottom:
output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
opacity: 0.8,
}}>
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Tool:</span>
<span style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.tool}
</span>
</div>
{pendingToolInfo.path && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Path:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.path}
</span>
</div>
)}
{pendingToolInfo.command && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Command:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.command}
</span>
</div>
)}
{pendingToolInfo.content && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Content Preview:</span>
<div
style={{
marginTop: 4,
padding: 6,
backgroundColor: CODE_BLOCK_BG_COLOR,
borderRadius: 3,
fontFamily: "monospace",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{pendingToolInfo.content}
{pendingToolInfo.content.length >= 200 && "..."}
</div>
</div>
)}
{pendingToolInfo.diff && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Diff Preview:</span>
<div
style={{
marginTop: 4,
padding: 6,
backgroundColor: CODE_BLOCK_BG_COLOR,
borderRadius: 3,
fontFamily: "monospace",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{pendingToolInfo.diff}
{pendingToolInfo.diff.length >= 200 && "..."}
</div>
</div>
)}
{pendingToolInfo.regex && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Regex:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.regex}
</span>
</div>
)}
{pendingToolInfo.url && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>URL:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.url}
</span>
</div>
)}
{pendingToolInfo.mcpServer && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>MCP Server:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.mcpServer}
</span>
</div>
)}
{pendingToolInfo.mcpTool && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>MCP Tool:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.mcpTool}
</span>
</div>
)}
{pendingToolInfo.resourceUri && (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>Resource URI:</span>
<span
className="ph-no-capture"
style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{pendingToolInfo.resourceUri}
</span>
</div>
)}
</div>
)}
{output.length > 0 && (
<CommandOutput
isContainerExpanded={true}