Files
gh-kylesnowschwartz-simplec…/hooks/handlers/copy_message_handler.rb
2025-11-30 08:36:27 +08:00

214 lines
6.8 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'claude_hooks'
require 'json'
require 'rbconfig'
require_relative 'transcript_parser'
# CopyMessageHandler
#
# PURPOSE: Handle --copy-prompt and --copy-response commands to copy messages from transcript
# TRIGGERS: When user submits a prompt starting with '--copy-prompt' or '--copy-response'
#
# COMMAND FORMATS:
# - --copy-prompt [number] - Copy last N prompts (default: 1, most recent)
# - --copy-prompt 5 - Copy the last 5 prompts
# - --copy-response [number] - Copy last N responses (default: 1, most recent)
# - --copy-response 5 - Copy the last 5 responses
class CopyMessageHandler < ClaudeHooks::UserPromptSubmit
def call
return unless current_prompt.start_with?('--copy-prompt', '--copy-response')
log "Processing copy command: #{current_prompt}"
# Determine message type and parse command arguments
message_type = current_prompt.start_with?('--copy-prompt') ? 'prompt' : 'response'
args = parse_copy_command(current_prompt, message_type)
begin
messages = extract_messages_from_transcript(message_type)
result = handle_copy_command(messages, args, message_type)
if result[:success]
copy_to_clipboard(result[:content]) if result[:content]
block_prompt!(result[:message])
else
log result[:error], level: :error
block_prompt!("Error: #{result[:error]}")
end
rescue StandardError => e
log "Copy #{message_type} failed: #{e.message}", level: :error
block_prompt!("Failed to copy #{message_type}: #{e.message}")
end
output
end
private
def parse_copy_command(command, type)
args = {
number: 1
}
# Parse number from command (default to 1 if no number specified)
case command
when /^--copy-#{type}\s+(\d+)$/
args[:number] = ::Regexp.last_match(1).to_i
end
args
end
def handle_copy_command(items, args, type)
return { success: false, error: "No #{type}s found" } if items.empty?
count = args[:number]
return { success: false, error: "#{type.capitalize} count must be at least 1" } if count < 1
if count > items.length
return { success: false, error: "Only #{items.length} #{type}#{'s' if items.length != 1} available" }
end
# Take the last N messages (first N from newest-first array)
selected_items = items.first(count)
# Reverse to show in chronological order (oldest to newest)
combined_content = selected_items.reverse.join("\n\n")
message_label = count == 1 ? type.capitalize : "Last #{count} #{type}s"
preview = generate_preview(combined_content)
{
success: true,
content: combined_content,
message: "#{message_label} copied to clipboard: #{preview}"
}
end
def extract_messages_from_transcript(message_type)
return [] unless transcript_path && File.exist?(transcript_path)
if message_type == 'prompt'
extract_prompts_from_transcript
else
extract_responses_from_transcript
end
end
def extract_prompts_from_transcript
request_groups = {}
begin
# Use defensive parser to handle variable content formats
parsed_entries = TranscriptParser.parse_transcript_file(transcript_path, strict: false)
parsed_entries.each do |entry|
# Skip non-user messages and parse errors
next unless entry.dig(:message, :role) == 'user'
next if entry[:parse_error]
request_id = entry[:request_id]
content = entry.dig(:message, :content) || ''
# Group by request ID to handle multi-part prompts
if request_groups[request_id]
request_groups[request_id] += "\n#{content}" if content && !content.empty?
else
request_groups[request_id] = content
end
end
rescue TranscriptParser::TranscriptParseError => e
log "Failed to parse transcript file: #{e.message}", level: :error
return []
rescue StandardError => e
log "Unexpected error parsing transcript: #{e.message}", level: :error
return []
end
# Convert to array (newest first)
request_groups.values.reverse
end
def extract_responses_from_transcript
request_groups = {}
begin
# Use defensive parser to handle variable content formats
parsed_entries = TranscriptParser.parse_transcript_file(transcript_path, strict: false)
parsed_entries.each do |entry|
# Skip non-assistant messages and parse errors
next unless entry.dig(:message, :role) == 'assistant'
next if entry[:parse_error]
request_id = entry[:request_id]
content = entry.dig(:message, :content) || ''
# Group by request ID to handle multi-part responses
if request_groups[request_id]
request_groups[request_id] += "\n#{content}" if content && !content.empty?
else
request_groups[request_id] = content
end
end
rescue TranscriptParser::TranscriptParseError => e
log "Failed to parse transcript file: #{e.message}", level: :error
return []
rescue StandardError => e
log "Unexpected error parsing transcript: #{e.message}", level: :error
return []
end
# Convert to array (newest first)
request_groups.values.reverse
end
def generate_preview(text)
return '<empty>' if text.nil? || text.strip.empty?
# Get first non-empty line, truncate if too long
preview = text.lines.find { |line| !line.strip.empty? }&.strip || text.strip
preview = "#{preview[0..59]}..." if preview.length > 60
preview
end
def copy_to_clipboard(text)
require 'open3'
case RbConfig::CONFIG['host_os']
when /darwin/
Open3.popen3('pbcopy') { |stdin, _stdout, _stderr, _thread| stdin.write(text) }
when /linux/
if system('which xclip >/dev/null 2>&1')
Open3.popen3('xclip', '-selection', 'clipboard') { |stdin, _stdout, _stderr, _thread| stdin.write(text) }
else
log 'xclip not found - clipboard copy failed', level: :error
raise 'xclip not available'
end
when /mswin|mingw|cygwin/
# Windows - write to temp file then use PowerShell
require 'tempfile'
temp_file = Tempfile.new('claude_copy')
temp_file.write("\xEF\xBB\xBF#{text}") # UTF-8 BOM
temp_file.close
system("powershell.exe -Command \"Get-Content -Path '#{temp_file.path}' -Encoding UTF8 | Set-Clipboard\"")
temp_file.unlink
else
log 'Unsupported platform for clipboard operations', level: :error
raise 'Unsupported platform'
end
log "Copied #{text.length} characters to clipboard"
end
end
# Testing support - run this file directly to test with sample data
if __FILE__ == $PROGRAM_NAME
ClaudeHooks::CLI.test_runner(CopyMessageHandler) do |input_data|
input_data['prompt'] = '--copy-prompt 1'
input_data['session_id'] = 'test-session-01'
end
end