Class: Crimson::OutputHandler

Inherits:
Object
  • Object
show all
Defined in:
lib/crimson/output_handler.rb

Overview

Streaming output handler with spinner, tool call logging, and usage statistics. Subscribes to agent events to provide real-time terminal feedback.

Constant Summary collapse

RENDER_INTERVAL =

Interval in seconds between render flushes.

0.05
SPINNER_FRAMES =

Spinner animation frame characters.

["", "", "", "", "", "", "", "", "", ""].freeze
TOOL_STYLES =

Visual styles for known tools (prefix color and label).

{
  "read_file"      => { prefix: "→Read",   color: :blue   },
  "write_file"     => { prefix: "→Write",  color: :green  },
  "edit_file"      => { prefix: "→Edit",   color: :yellow },
  "run_command"    => { prefix: "$",        color: :bright_white },
  "search_files"   => { prefix: "✱Search", color: :cyan   },
  "glob"           => { prefix: "✱Glob",   color: :cyan   },
  "list_directory" => { prefix: "→List",   color: :cyan   }
}.freeze
TOOL_ARG_EXTRACTORS =

Extractors to pull display-relevant arguments from tool call argument hashes.

{
  "read_file"      => ->(a) { a["path"] || a[:path] },
  "write_file"     => ->(a) { a["path"] || a[:path] },
  "edit_file"      => ->(a) { a["path"] || a[:path] },
  "run_command"    => ->(a) { a["command"] || a[:command] },
  "search_files"   => ->(a) { a["pattern"] || a[:pattern] },
  "list_directory" => ->(a) { a["path"] || a[:path] },
  "glob"           => ->(a) { a["pattern"] || a[:pattern] }
}.freeze

Instance Method Summary collapse

Constructor Details

#initializeOutputHandler

Returns a new instance of OutputHandler.



37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/crimson/output_handler.rb', line 37

def initialize
  @pastel = Pastel.new
  @spinner_active = false
  @first_token = false
  @render_buffer = String.new
  @render_thread = nil
  @render_mutex = Mutex.new
  @spinner_thread = nil
  @seen_tool_calls = Set.new
  @thinking_start = nil
  @run_start = nil
end

Instance Method Details

#attach(agent) ⇒ void

This method returns an undefined value.

Subscribe to events on the given agent for output rendering.

Parameters:



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/crimson/output_handler.rb', line 53

def attach(agent)
  agent.on(Agent::Events::AGENT_START) do
    @first_token = false
    Formatter.reset
    @thinking_start = Time.now
    @run_start = Time.now
    start_spinner
  end

  agent.on(Agent::Events::MESSAGE_UPDATE) do |_event, delta:, **|
    unless @first_token
      stop_spinner
      @first_token = true
      if @thinking_start
        elapsed = format("%.1fs", Time.now - @thinking_start)
        puts @pastel.dim("+ Thought: #{elapsed}")
        @thinking_start = nil
      end
    end
    @render_mutex.synchronize { @render_buffer << delta }
    start_render_thread unless @render_thread&.alive?
  end

  agent.on(Agent::Events::TOOL_EXECUTION_START) do |_event, tool_name:, args:, tool_call_id:, **|
    next if @seen_tool_calls.include?(tool_call_id)
    @seen_tool_calls << tool_call_id
    stop_spinner
    $stdout.write("\r\e[2K")
    $stdout.flush
    log_tool_call(tool_name, args)
  end

  agent.on(Agent::Events::TOOL_EXECUTION_END) do |_event, result:, is_error:, tool_call_id:, **|
    next if tool_call_id && @seen_tool_calls.include?("#{tool_call_id}_end")
    @seen_tool_calls << "#{tool_call_id}_end" if tool_call_id
    next unless is_error
    truncated = truncate(result.to_s, 120)
    puts @pastel.red("#{truncated}")
  end

  agent.on(Agent::Events::TOOL_EXECUTION_UPDATE) do |_event, tool_name:, partial_result:, **|
    next unless tool_name == "run_command"
    flush_render_buffer
    $stdout.write("\r\e[2K #{@pastel.dim(partial_result)}")
    $stdout.flush
  end

  agent.on(Agent::Events::TURN_START) do |_event, active_skills: []|
    conditional = active_skills.reject { |s| s == "coding" }
    unless conditional.empty?
      stop_spinner
      puts @pastel.dim("+ #{conditional.join(", ")}")
    end
    unless @first_token
      @thinking_start = Time.now
      start_spinner
    end
  end

  agent.on(Agent::Events::AGENT_END) do
    stop_spinner
    flush_render_buffer(final: true)
    @seen_tool_calls.clear
    elapsed = @run_start ? format_elapsed(Time.now - @run_start) : ""
    usage = agent.token_usage
    parts = []
    if usage[:total] > 0
      cost = agent.cost_tracker.total_cost
      cost_str = cost > 0 ? " ($#{format("%.4f", cost)})" : ""
      parts << "tokens: #{usage[:prompt]}#{usage[:completion]}↓ = #{usage[:total]}#{cost_str}"
    end
    parts << "time: #{elapsed}" unless elapsed.empty?
    puts @pastel.dim("\n  #{parts.join("  ·  ")}") unless parts.empty?
  end
end