crimson-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/exe/crimson +207 -0
- data/lib/crimson/agent/event_emitter.rb +56 -0
- data/lib/crimson/agent/events.rb +43 -0
- data/lib/crimson/agent/steering.rb +91 -0
- data/lib/crimson/agent/tool_executor.rb +114 -0
- data/lib/crimson/agent.rb +564 -0
- data/lib/crimson/client/anthropic_adapter.rb +206 -0
- data/lib/crimson/client/base.rb +25 -0
- data/lib/crimson/client/factory.rb +27 -0
- data/lib/crimson/client/openai_adapter.rb +188 -0
- data/lib/crimson/compactor.rb +129 -0
- data/lib/crimson/config.rb +95 -0
- data/lib/crimson/cost_tracker.rb +62 -0
- data/lib/crimson/formatter.rb +93 -0
- data/lib/crimson/message.rb +177 -0
- data/lib/crimson/output_handler.rb +252 -0
- data/lib/crimson/project_context.rb +184 -0
- data/lib/crimson/providers.rb +49 -0
- data/lib/crimson/repl.rb +310 -0
- data/lib/crimson/retry_handler.rb +104 -0
- data/lib/crimson/session_entry.rb +145 -0
- data/lib/crimson/session_manager.rb +219 -0
- data/lib/crimson/setup.rb +134 -0
- data/lib/crimson/skill_router.rb +165 -0
- data/lib/crimson/token_counter.rb +84 -0
- data/lib/crimson/tool_registry.rb +112 -0
- data/lib/crimson/tools/diff_util.rb +44 -0
- data/lib/crimson/tools/edit_file.rb +145 -0
- data/lib/crimson/tools/file_mutation_queue.rb +30 -0
- data/lib/crimson/tools/glob.rb +49 -0
- data/lib/crimson/tools/index.rb +20 -0
- data/lib/crimson/tools/list_directory.rb +42 -0
- data/lib/crimson/tools/read_file.rb +92 -0
- data/lib/crimson/tools/run_command.rb +138 -0
- data/lib/crimson/tools/schema.rb +60 -0
- data/lib/crimson/tools/search_files.rb +107 -0
- data/lib/crimson/tools/truncator.rb +94 -0
- data/lib/crimson/tools/write_file.rb +53 -0
- data/lib/crimson/trust_manager.rb +102 -0
- data/lib/crimson/version.rb +6 -0
- data/lib/crimson.rb +55 -0
- data/skills/coding.md +49 -0
- data/skills/debugging.md +32 -0
- data/skills/git.md +37 -0
- data/skills/planning.md +56 -0
- data/skills/refactoring.md +37 -0
- data/skills/research.md +37 -0
- data/skills/review.md +37 -0
- data/skills/security.md +42 -0
- data/skills/testing.md +37 -0
- data/skills/writing.md +43 -0
- metadata +294 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
# Model pricing in USD per million tokens.
|
|
5
|
+
# @!parse
|
|
6
|
+
# MODEL_PRICING = Hash[String, Hash]
|
|
7
|
+
MODEL_PRICING = {
|
|
8
|
+
"gpt-4o" => { input: 2.50, output: 10.00 },
|
|
9
|
+
"gpt-4o-mini" => { input: 0.15, output: 0.60 },
|
|
10
|
+
"gpt-4-turbo" => { input: 10.00, output: 30.00 },
|
|
11
|
+
"gpt-3.5-turbo" => { input: 0.50, output: 1.50 },
|
|
12
|
+
"claude-sonnet-4-20250514" => { input: 3.00, output: 15.00 },
|
|
13
|
+
"claude-3-5-sonnet-20241022" => { input: 3.00, output: 15.00 },
|
|
14
|
+
"claude-3-5-haiku-20241022" => { input: 0.80, output: 4.00 },
|
|
15
|
+
"claude-3-opus-20240229" => { input: 15.00, output: 75.00 },
|
|
16
|
+
"claude-3-haiku-20240307" => { input: 0.25, output: 1.25 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Per-model cost tracking for API usage.
|
|
20
|
+
class CostTracker
|
|
21
|
+
# @return [Float] accumulated cost in USD
|
|
22
|
+
attr_reader :total_cost
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@total_cost = 0.0
|
|
26
|
+
@cost_breakdown = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Record usage for a single turn.
|
|
30
|
+
# @param model [String] model name
|
|
31
|
+
# @param usage [Hash, nil] token usage with prompt_tokens/completion_tokens
|
|
32
|
+
# @return [Hash] input/output/total cost for this turn
|
|
33
|
+
def track(model, usage)
|
|
34
|
+
pricing = MODEL_PRICING[model]
|
|
35
|
+
return { input: 0, output: 0, total: 0 } unless pricing && usage
|
|
36
|
+
|
|
37
|
+
prompt_tokens = usage[:prompt_tokens] || usage["prompt_tokens"] || usage[:prompt] || 0
|
|
38
|
+
completion_tokens = usage[:completion_tokens] || usage["completion_tokens"] || usage[:completion] || 0
|
|
39
|
+
|
|
40
|
+
input_cost = (pricing[:input] / 1_000_000.0) * prompt_tokens
|
|
41
|
+
output_cost = (pricing[:output] / 1_000_000.0) * completion_tokens
|
|
42
|
+
turn_cost = input_cost + output_cost
|
|
43
|
+
|
|
44
|
+
@total_cost += turn_cost
|
|
45
|
+
@cost_breakdown << { input: input_cost, output: output_cost, total: turn_cost }
|
|
46
|
+
|
|
47
|
+
{ input: input_cost, output: output_cost, total: turn_cost }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Hash>] per-turn cost breakdown
|
|
51
|
+
def breakdown
|
|
52
|
+
@cost_breakdown.dup
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reset all tracked costs.
|
|
56
|
+
# @return [void]
|
|
57
|
+
def reset
|
|
58
|
+
@total_cost = 0.0
|
|
59
|
+
@cost_breakdown = []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
# ANSI-markdown formatter for rendering Markdown-formatted text with terminal colors.
|
|
7
|
+
module Formatter
|
|
8
|
+
@pastel = Pastel.new(enabled: true)
|
|
9
|
+
@in_code_block = false
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Reset the code block tracking state.
|
|
13
|
+
# @return [void]
|
|
14
|
+
def reset
|
|
15
|
+
@in_code_block = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Format a single line of Markdown text with ANSI styling.
|
|
19
|
+
# @param line [String, nil]
|
|
20
|
+
# @return [String] styled output
|
|
21
|
+
def format_line(line)
|
|
22
|
+
return "" if line.nil?
|
|
23
|
+
|
|
24
|
+
if @in_code_block
|
|
25
|
+
if line.start_with?("```")
|
|
26
|
+
@in_code_block = false
|
|
27
|
+
@pastel.dim("```")
|
|
28
|
+
else
|
|
29
|
+
line
|
|
30
|
+
end
|
|
31
|
+
elsif line.start_with?("```")
|
|
32
|
+
@in_code_block = true
|
|
33
|
+
lang = line.sub(/^```\s*/, "").strip
|
|
34
|
+
lang.empty? ? @pastel.dim("```") : @pastel.dim("``` #{lang}")
|
|
35
|
+
else
|
|
36
|
+
style_inline(line)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] whether currently inside a code block
|
|
41
|
+
def in_code_block?
|
|
42
|
+
@in_code_block
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Apply inline Markdown styling (headers, bold, code, links, etc.).
|
|
48
|
+
# @api private
|
|
49
|
+
def style_inline(line)
|
|
50
|
+
result = line.dup
|
|
51
|
+
|
|
52
|
+
header_re = Regexp.new('^\#{1,6}\s+(.+)$')
|
|
53
|
+
if (m = result.match(header_re))
|
|
54
|
+
return @pastel.bold.yellow(m[1])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result = result.gsub(/^(\s{0,3})([-*+])\s/) do
|
|
58
|
+
"#{$1}#{@pastel.cyan($2)} "
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = result.gsub(/^(\s{0,3})(\d+\.)\s/) do
|
|
62
|
+
"#{$1}#{@pastel.cyan($2)} "
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
result = result.gsub(/^>\s?(.*)$/) do
|
|
66
|
+
@pastel.dim("│ ") + @pastel.dim($1)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if result.strip =~ /^-{3,}$|^_{3,}$|^\*{3,}$/
|
|
70
|
+
result = @pastel.dim("─" * 40)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result = result.gsub(/\[([^\]]+)\]\(([^)]+)\)/) do
|
|
74
|
+
@pastel.underline($1) + @pastel.dim(" (#{$2})")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result = result.gsub(/`([^`]+)`/) do
|
|
78
|
+
@pastel.cyan($1)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result = result.gsub(/\*\*([^*]+)\*\*/) do
|
|
82
|
+
@pastel.bold($1)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
result = result.gsub(/(?<!\*)\*([^*]+)\*(?!\*)/) do
|
|
86
|
+
@pastel.italic($1)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
# Namespace for message types used in conversation history.
|
|
7
|
+
module Message
|
|
8
|
+
# Base class for all message types.
|
|
9
|
+
# @abstract
|
|
10
|
+
class Base
|
|
11
|
+
# @return [String] the role name (e.g. "system", "user", "assistant", "tool")
|
|
12
|
+
attr_reader :role
|
|
13
|
+
|
|
14
|
+
# @param role [String]
|
|
15
|
+
def initialize(role)
|
|
16
|
+
@role = role
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A system-level message carrying instructions or context.
|
|
21
|
+
class System < Base
|
|
22
|
+
# @return [String] the system message content
|
|
23
|
+
attr_reader :content
|
|
24
|
+
|
|
25
|
+
# @param content [String]
|
|
26
|
+
def initialize(content)
|
|
27
|
+
super("system")
|
|
28
|
+
@content = content
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Hash] OpenAI-compatible representation
|
|
32
|
+
def to_openai_h
|
|
33
|
+
{ role: "system", content: @content }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] Anthropic-compatible representation
|
|
37
|
+
def to_anthropic_h
|
|
38
|
+
{ type: "text", text: @content }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A user message.
|
|
43
|
+
class User < Base
|
|
44
|
+
# @return [String] the user message content
|
|
45
|
+
attr_reader :content
|
|
46
|
+
|
|
47
|
+
# @param content [String]
|
|
48
|
+
def initialize(content)
|
|
49
|
+
super("user")
|
|
50
|
+
@content = content
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Hash] OpenAI-compatible representation
|
|
54
|
+
def to_openai_h
|
|
55
|
+
{ role: "user", content: @content }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash] Anthropic-compatible representation
|
|
59
|
+
def to_anthropic_h
|
|
60
|
+
{ role: "user", content: @content }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# An assistant (model) response, optionally containing tool calls.
|
|
65
|
+
class Assistant < Base
|
|
66
|
+
# @return [String, nil] the text content
|
|
67
|
+
# @return [Array<ToolCall>] any tool calls requested
|
|
68
|
+
attr_reader :content, :tool_calls
|
|
69
|
+
|
|
70
|
+
# @param content [String, nil]
|
|
71
|
+
# @param tool_calls [Array<ToolCall>]
|
|
72
|
+
def initialize(content: nil, tool_calls: [])
|
|
73
|
+
super("assistant")
|
|
74
|
+
@content = content
|
|
75
|
+
@tool_calls = tool_calls
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] whether this message contains tool calls
|
|
79
|
+
def tool_call?
|
|
80
|
+
!@tool_calls.nil? && !@tool_calls.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Hash] OpenAI-compatible representation
|
|
84
|
+
def to_openai_h
|
|
85
|
+
h = { role: "assistant" }
|
|
86
|
+
h[:content] = @content if @content
|
|
87
|
+
h[:tool_calls] = @tool_calls.map(&:to_openai_h) if tool_call?
|
|
88
|
+
h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash] Anthropic-compatible representation
|
|
92
|
+
def to_anthropic_h
|
|
93
|
+
content_blocks = []
|
|
94
|
+
content_blocks << { type: "text", text: @content } if @content && !@content.empty?
|
|
95
|
+
@tool_calls.each do |tc|
|
|
96
|
+
content_blocks << {
|
|
97
|
+
type: "tool_use",
|
|
98
|
+
id: tc.id,
|
|
99
|
+
name: tc.name,
|
|
100
|
+
input: tc.arguments
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
{ role: "assistant", content: content_blocks }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Represents a tool/function call requested by the model.
|
|
108
|
+
class ToolCall
|
|
109
|
+
# @return [String] unique identifier for this tool call
|
|
110
|
+
# @return [String] name of the tool
|
|
111
|
+
# @return [Hash] arguments passed to the tool
|
|
112
|
+
attr_reader :id, :name, :arguments
|
|
113
|
+
|
|
114
|
+
# @param id [String]
|
|
115
|
+
# @param name [String]
|
|
116
|
+
# @param arguments [Hash]
|
|
117
|
+
def initialize(id:, name:, arguments: {})
|
|
118
|
+
@id = id
|
|
119
|
+
@name = name
|
|
120
|
+
@arguments = arguments
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Hash] OpenAI-compatible tool call representation
|
|
124
|
+
def to_openai_h
|
|
125
|
+
{
|
|
126
|
+
id: @id,
|
|
127
|
+
type: "function",
|
|
128
|
+
function: {
|
|
129
|
+
name: @name,
|
|
130
|
+
arguments: JSON.generate(@arguments)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The result of executing a tool call, sent back to the model.
|
|
137
|
+
class ToolResult < Base
|
|
138
|
+
# @return [String] the ID of the tool call this result belongs to
|
|
139
|
+
# @return [String] the tool name
|
|
140
|
+
# @return [String] the result content
|
|
141
|
+
attr_reader :tool_call_id, :name, :content
|
|
142
|
+
|
|
143
|
+
# @param tool_call_id [String]
|
|
144
|
+
# @param name [String]
|
|
145
|
+
# @param content [String]
|
|
146
|
+
def initialize(tool_call_id:, name:, content:)
|
|
147
|
+
super("tool")
|
|
148
|
+
@tool_call_id = tool_call_id
|
|
149
|
+
@name = name
|
|
150
|
+
@content = content
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [Hash] OpenAI-compatible representation
|
|
154
|
+
def to_openai_h
|
|
155
|
+
{
|
|
156
|
+
role: "tool",
|
|
157
|
+
tool_call_id: @tool_call_id,
|
|
158
|
+
content: @content
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [Hash] Anthropic-compatible representation
|
|
163
|
+
def to_anthropic_h
|
|
164
|
+
{
|
|
165
|
+
role: "user",
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "tool_result",
|
|
169
|
+
tool_use_id: @tool_call_id,
|
|
170
|
+
content: @content
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
# Streaming output handler with spinner, tool call logging, and usage statistics.
|
|
8
|
+
# Subscribes to agent events to provide real-time terminal feedback.
|
|
9
|
+
class OutputHandler
|
|
10
|
+
# Interval in seconds between render flushes.
|
|
11
|
+
RENDER_INTERVAL = 0.05
|
|
12
|
+
# Spinner animation frame characters.
|
|
13
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
|
|
14
|
+
|
|
15
|
+
# Visual styles for known tools (prefix color and label).
|
|
16
|
+
TOOL_STYLES = {
|
|
17
|
+
"read_file" => { prefix: "→Read", color: :blue },
|
|
18
|
+
"write_file" => { prefix: "→Write", color: :green },
|
|
19
|
+
"edit_file" => { prefix: "→Edit", color: :yellow },
|
|
20
|
+
"run_command" => { prefix: "$", color: :bright_white },
|
|
21
|
+
"search_files" => { prefix: "✱Search", color: :cyan },
|
|
22
|
+
"glob" => { prefix: "✱Glob", color: :cyan },
|
|
23
|
+
"list_directory" => { prefix: "→List", color: :cyan }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Extractors to pull display-relevant arguments from tool call argument hashes.
|
|
27
|
+
TOOL_ARG_EXTRACTORS = {
|
|
28
|
+
"read_file" => ->(a) { a["path"] || a[:path] },
|
|
29
|
+
"write_file" => ->(a) { a["path"] || a[:path] },
|
|
30
|
+
"edit_file" => ->(a) { a["path"] || a[:path] },
|
|
31
|
+
"run_command" => ->(a) { a["command"] || a[:command] },
|
|
32
|
+
"search_files" => ->(a) { a["pattern"] || a[:pattern] },
|
|
33
|
+
"list_directory" => ->(a) { a["path"] || a[:path] },
|
|
34
|
+
"glob" => ->(a) { a["pattern"] || a[:pattern] }
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@pastel = Pastel.new
|
|
39
|
+
@spinner_active = false
|
|
40
|
+
@first_token = false
|
|
41
|
+
@render_buffer = String.new
|
|
42
|
+
@render_thread = nil
|
|
43
|
+
@render_mutex = Mutex.new
|
|
44
|
+
@spinner_thread = nil
|
|
45
|
+
@seen_tool_calls = Set.new
|
|
46
|
+
@thinking_start = nil
|
|
47
|
+
@run_start = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Subscribe to events on the given agent for output rendering.
|
|
51
|
+
# @param agent [Agent]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def attach(agent)
|
|
54
|
+
agent.on(Agent::Events::AGENT_START) do
|
|
55
|
+
@first_token = false
|
|
56
|
+
Formatter.reset
|
|
57
|
+
@thinking_start = Time.now
|
|
58
|
+
@run_start = Time.now
|
|
59
|
+
start_spinner
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
agent.on(Agent::Events::MESSAGE_UPDATE) do |_event, delta:, **|
|
|
63
|
+
unless @first_token
|
|
64
|
+
stop_spinner
|
|
65
|
+
@first_token = true
|
|
66
|
+
if @thinking_start
|
|
67
|
+
elapsed = format("%.1fs", Time.now - @thinking_start)
|
|
68
|
+
puts @pastel.dim("+ Thought: #{elapsed}")
|
|
69
|
+
@thinking_start = nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@render_mutex.synchronize { @render_buffer << delta }
|
|
73
|
+
start_render_thread unless @render_thread&.alive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
agent.on(Agent::Events::TOOL_EXECUTION_START) do |_event, tool_name:, args:, tool_call_id:, **|
|
|
77
|
+
next if @seen_tool_calls.include?(tool_call_id)
|
|
78
|
+
@seen_tool_calls << tool_call_id
|
|
79
|
+
stop_spinner
|
|
80
|
+
$stdout.write("\r\e[2K")
|
|
81
|
+
$stdout.flush
|
|
82
|
+
log_tool_call(tool_name, args)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
agent.on(Agent::Events::TOOL_EXECUTION_END) do |_event, result:, is_error:, tool_call_id:, **|
|
|
86
|
+
next if tool_call_id && @seen_tool_calls.include?("#{tool_call_id}_end")
|
|
87
|
+
@seen_tool_calls << "#{tool_call_id}_end" if tool_call_id
|
|
88
|
+
next unless is_error
|
|
89
|
+
truncated = truncate(result.to_s, 120)
|
|
90
|
+
puts @pastel.red(" ✗ #{truncated}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
agent.on(Agent::Events::TOOL_EXECUTION_UPDATE) do |_event, tool_name:, partial_result:, **|
|
|
94
|
+
next unless tool_name == "run_command"
|
|
95
|
+
flush_render_buffer
|
|
96
|
+
$stdout.write("\r\e[2K #{@pastel.dim(partial_result)}")
|
|
97
|
+
$stdout.flush
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
agent.on(Agent::Events::TURN_START) do |_event, active_skills: []|
|
|
101
|
+
conditional = active_skills.reject { |s| s == "coding" }
|
|
102
|
+
unless conditional.empty?
|
|
103
|
+
stop_spinner
|
|
104
|
+
puts @pastel.dim("+ #{conditional.join(", ")}")
|
|
105
|
+
end
|
|
106
|
+
unless @first_token
|
|
107
|
+
@thinking_start = Time.now
|
|
108
|
+
start_spinner
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
agent.on(Agent::Events::AGENT_END) do
|
|
113
|
+
stop_spinner
|
|
114
|
+
flush_render_buffer(final: true)
|
|
115
|
+
@seen_tool_calls.clear
|
|
116
|
+
elapsed = @run_start ? format_elapsed(Time.now - @run_start) : ""
|
|
117
|
+
usage = agent.token_usage
|
|
118
|
+
parts = []
|
|
119
|
+
if usage[:total] > 0
|
|
120
|
+
cost = agent.cost_tracker.total_cost
|
|
121
|
+
cost_str = cost > 0 ? " ($#{format("%.4f", cost)})" : ""
|
|
122
|
+
parts << "tokens: #{usage[:prompt]}↑ #{usage[:completion]}↓ = #{usage[:total]}#{cost_str}"
|
|
123
|
+
end
|
|
124
|
+
parts << "time: #{elapsed}" unless elapsed.empty?
|
|
125
|
+
puts @pastel.dim("\n #{parts.join(" · ")}") unless parts.empty?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# @api private
|
|
132
|
+
def log_tool_call(tool_name, args)
|
|
133
|
+
style = TOOL_STYLES[tool_name]
|
|
134
|
+
if style
|
|
135
|
+
detail = extract_tool_arg(tool_name, args) || ""
|
|
136
|
+
puts @pastel.decorate("#{style[:prefix]} ", style[:color]) + detail
|
|
137
|
+
else
|
|
138
|
+
detail = extract_tool_arg(tool_name, args)
|
|
139
|
+
puts @pastel.dim("→ #{tool_name}") + (detail ? " #{detail}" : "")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @api private
|
|
144
|
+
def format_elapsed(seconds)
|
|
145
|
+
if seconds < 60
|
|
146
|
+
format("%.1fs", seconds)
|
|
147
|
+
else
|
|
148
|
+
mins = (seconds / 60).to_i
|
|
149
|
+
secs = format("%.0f", seconds - mins * 60)
|
|
150
|
+
"#{mins}m #{secs}s"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @api private
|
|
155
|
+
def start_spinner
|
|
156
|
+
return if @spinner_active
|
|
157
|
+
@spinner_active = true
|
|
158
|
+
@spinner_thread = Thread.new do
|
|
159
|
+
i = 0
|
|
160
|
+
while @spinner_active
|
|
161
|
+
$stdout.write("\r \e[36m#{SPINNER_FRAMES[i % SPINNER_FRAMES.length]}\e[0m Thinking...")
|
|
162
|
+
$stdout.flush
|
|
163
|
+
i += 1
|
|
164
|
+
sleep 0.08
|
|
165
|
+
end
|
|
166
|
+
$stdout.write("\r\e[2K")
|
|
167
|
+
$stdout.flush
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @api private
|
|
172
|
+
def stop_spinner
|
|
173
|
+
return unless @spinner_active
|
|
174
|
+
@spinner_active = false
|
|
175
|
+
@spinner_thread&.join(2)
|
|
176
|
+
@spinner_thread = nil
|
|
177
|
+
$stdout.write("\r\e[2K")
|
|
178
|
+
$stdout.flush
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @api private
|
|
182
|
+
def start_render_thread
|
|
183
|
+
@render_thread = Thread.new do
|
|
184
|
+
loop do
|
|
185
|
+
sleep RENDER_INTERVAL
|
|
186
|
+
break if flush_render_buffer == :empty
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @api private
|
|
192
|
+
def flush_render_buffer(final: false)
|
|
193
|
+
data = nil
|
|
194
|
+
@render_mutex.synchronize do
|
|
195
|
+
data = @render_buffer.dup
|
|
196
|
+
@render_buffer.clear
|
|
197
|
+
end
|
|
198
|
+
return :empty if data.nil? || data.empty?
|
|
199
|
+
|
|
200
|
+
output = String.new
|
|
201
|
+
lines = if final
|
|
202
|
+
data.split("\n", -1)
|
|
203
|
+
else
|
|
204
|
+
last_newline = data.rindex("\n")
|
|
205
|
+
if last_newline.nil?
|
|
206
|
+
@render_mutex.synchronize { @render_buffer.prepend(data) }
|
|
207
|
+
return :empty
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
complete = data[0..last_newline]
|
|
211
|
+
remainder = data[(last_newline + 1)..]
|
|
212
|
+
@render_mutex.synchronize { @render_buffer.prepend(remainder) } if remainder
|
|
213
|
+
complete.split("\n", -1)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
lines.each do |line|
|
|
217
|
+
next if line.nil?
|
|
218
|
+
next if line.strip.empty?
|
|
219
|
+
if !output.empty? && header?(line)
|
|
220
|
+
output << "\n"
|
|
221
|
+
end
|
|
222
|
+
styled = Formatter.format_line(line)
|
|
223
|
+
output << styled << "\n"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
$stdout.write(output)
|
|
227
|
+
$stdout.flush
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @api private
|
|
232
|
+
def extract_tool_arg(tool_name, args)
|
|
233
|
+
return nil unless args.is_a?(Hash)
|
|
234
|
+
extractor = TOOL_ARG_EXTRACTORS[tool_name]
|
|
235
|
+
extractor ? extractor.call(args) : nil
|
|
236
|
+
rescue
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @api private
|
|
241
|
+
def header?(line)
|
|
242
|
+
line.match?(Regexp.new('^\#{1,6}\s'))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @api private
|
|
246
|
+
def truncate(text, max_len)
|
|
247
|
+
return "" if text.nil?
|
|
248
|
+
cleaned = text.gsub("\n", "\\n")
|
|
249
|
+
cleaned.length > max_len ? "#{cleaned[0...max_len]}..." : cleaned
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|