mirror of
https://github.com/cline/cline.git
synced 2026-09-15 04:14:34 +08:00
Hooks UI improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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__"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user