openclacky 0.5.6 → 0.6.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -95,7 +95,7 @@ module Clacky
|
|
|
95
95
|
if elapsed > soft_timeout && !soft_timeout_triggered
|
|
96
96
|
soft_timeout_triggered = true
|
|
97
97
|
|
|
98
|
-
# L1:
|
|
98
|
+
# L1: Check for interaction patterns
|
|
99
99
|
interaction = detect_interaction(stdout_buffer.string)
|
|
100
100
|
if interaction
|
|
101
101
|
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
@@ -107,24 +107,6 @@ module Clacky
|
|
|
107
107
|
max_output_lines
|
|
108
108
|
)
|
|
109
109
|
end
|
|
110
|
-
|
|
111
|
-
# L2:
|
|
112
|
-
last_size = stdout_buffer.size
|
|
113
|
-
stdin.puts("\n") rescue nil
|
|
114
|
-
sleep 2
|
|
115
|
-
|
|
116
|
-
if stdout_buffer.size > last_size
|
|
117
|
-
next
|
|
118
|
-
else
|
|
119
|
-
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
120
|
-
return format_stuck_result(
|
|
121
|
-
command,
|
|
122
|
-
stdout_buffer.string,
|
|
123
|
-
stderr_buffer.string,
|
|
124
|
-
elapsed,
|
|
125
|
-
max_output_lines
|
|
126
|
-
)
|
|
127
|
-
end
|
|
128
110
|
end
|
|
129
111
|
|
|
130
112
|
break unless wait_thr.alive?
|
|
@@ -255,36 +237,6 @@ module Clacky
|
|
|
255
237
|
MSG
|
|
256
238
|
end
|
|
257
239
|
|
|
258
|
-
def format_stuck_result(command, stdout, stderr, elapsed, max_output_lines)
|
|
259
|
-
{
|
|
260
|
-
command: command,
|
|
261
|
-
stdout: truncate_output(stdout, max_output_lines),
|
|
262
|
-
stderr: truncate_output(stderr, max_output_lines),
|
|
263
|
-
exit_code: -3,
|
|
264
|
-
success: false,
|
|
265
|
-
state: 'STUCK',
|
|
266
|
-
elapsed: elapsed,
|
|
267
|
-
message: format_stuck_message(truncate_output(stdout, max_output_lines), elapsed),
|
|
268
|
-
output_truncated: output_truncated?(stdout, stderr, max_output_lines)
|
|
269
|
-
}
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def format_stuck_message(output, elapsed)
|
|
273
|
-
<<~MSG
|
|
274
|
-
#{output}
|
|
275
|
-
|
|
276
|
-
#{'=' * 60}
|
|
277
|
-
[Terminal State: STUCK]
|
|
278
|
-
#{'=' * 60}
|
|
279
|
-
|
|
280
|
-
The terminal is not responding after #{elapsed.round(1)}s.
|
|
281
|
-
|
|
282
|
-
Suggested actions:
|
|
283
|
-
• Try interrupting with Ctrl+C
|
|
284
|
-
• Check if command is frozen
|
|
285
|
-
MSG
|
|
286
|
-
end
|
|
287
|
-
|
|
288
240
|
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
|
|
289
241
|
{
|
|
290
242
|
command: command,
|
|
@@ -40,8 +40,8 @@ module Clacky
|
|
|
40
40
|
# Fetch the web page
|
|
41
41
|
response = fetch_url(uri)
|
|
42
42
|
|
|
43
|
-
# Extract content
|
|
44
|
-
content = response.body
|
|
43
|
+
# Extract content and force UTF-8 encoding at the source
|
|
44
|
+
content = response.body.force_encoding('UTF-8').scrub('?')
|
|
45
45
|
content_type = response["content-type"] || ""
|
|
46
46
|
|
|
47
47
|
# Parse HTML if it's an HTML page
|
|
@@ -48,14 +48,14 @@ module Clacky
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def search_duckduckgo(query, max_results)
|
|
51
|
+
private def search_duckduckgo(query, max_results)
|
|
52
52
|
# DuckDuckGo HTML search endpoint
|
|
53
53
|
encoded_query = CGI.escape(query)
|
|
54
54
|
url = URI("https://html.duckduckgo.com/html/?q=#{encoded_query}")
|
|
55
55
|
|
|
56
56
|
# Make request with user agent
|
|
57
57
|
request = Net::HTTP::Get.new(url)
|
|
58
|
-
request["User-Agent"] = "Mozilla/5.0 (
|
|
58
|
+
request["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
|
59
59
|
|
|
60
60
|
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: 10) do |http|
|
|
61
61
|
http.request(request)
|
|
@@ -78,45 +78,57 @@ module Clacky
|
|
|
78
78
|
]
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
def parse_duckduckgo_html(html, max_results)
|
|
81
|
+
private def parse_duckduckgo_html(html, max_results)
|
|
82
82
|
results = []
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
# Ensure HTML is UTF-8 encoded
|
|
85
|
+
html = html.force_encoding('UTF-8') unless html.encoding == Encoding::UTF_8
|
|
86
|
+
|
|
87
|
+
# Extract all result links and snippets
|
|
88
|
+
# Pattern: <a class="result__a" href="//duckduckgo.com/l/?uddg=ENCODED_URL...">TITLE</a>
|
|
89
|
+
links = html.scan(%r{<a[^>]*class="result__a"[^>]*href="//duckduckgo\.com/l/\?uddg=([^"&]+)[^"]*"[^>]*>(.*?)</a>}m)
|
|
90
|
+
|
|
91
|
+
# Pattern: <a class="result__snippet">SNIPPET</a>
|
|
92
|
+
snippets = html.scan(%r{<a[^>]*class="result__snippet"[^>]*>(.*?)</a>}m)
|
|
93
|
+
|
|
94
|
+
# Combine links and snippets
|
|
95
|
+
links.each_with_index do |link_data, index|
|
|
87
96
|
break if results.length >= max_results
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
snippet = ""
|
|
96
|
-
|
|
97
|
-
snippet = $1.gsub(/<[^>]+>/, "").strip
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
results << {
|
|
101
|
-
title: title,
|
|
102
|
-
url: url,
|
|
103
|
-
snippet: snippet
|
|
104
|
-
}
|
|
98
|
+
url = CGI.unescape(link_data[0]).force_encoding('UTF-8')
|
|
99
|
+
title = link_data[1].gsub(/<[^>]+>/, "").strip
|
|
100
|
+
title = CGI.unescapeHTML(title) if title.include?("&")
|
|
101
|
+
|
|
102
|
+
snippet = ""
|
|
103
|
+
if snippets[index]
|
|
104
|
+
snippet = snippets[index][0].gsub(/<[^>]+>/, "").strip
|
|
105
|
+
snippet = CGI.unescapeHTML(snippet) if snippet.include?("&")
|
|
105
106
|
end
|
|
107
|
+
|
|
108
|
+
results << {
|
|
109
|
+
title: title,
|
|
110
|
+
url: url,
|
|
111
|
+
snippet: snippet
|
|
112
|
+
}
|
|
106
113
|
end
|
|
107
114
|
|
|
108
115
|
# If parsing failed, provide a fallback
|
|
109
116
|
if results.empty?
|
|
110
117
|
results << {
|
|
111
118
|
title: "Web search results",
|
|
112
|
-
url: "https://duckduckgo.com
|
|
113
|
-
snippet: "Could not parse search results.
|
|
119
|
+
url: "https://duckduckgo.com/",
|
|
120
|
+
snippet: "Could not parse search results. Please try again."
|
|
114
121
|
}
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
results
|
|
118
|
-
rescue StandardError
|
|
119
|
-
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
# Return fallback on error
|
|
127
|
+
[{
|
|
128
|
+
title: "Web search error",
|
|
129
|
+
url: "https://duckduckgo.com/",
|
|
130
|
+
snippet: "Error parsing results: #{e.message}"
|
|
131
|
+
}]
|
|
120
132
|
end
|
|
121
133
|
|
|
122
134
|
def format_call(args)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Clacky UI2 - MVC Terminal UI System
|
|
2
|
+
|
|
3
|
+
A modern, MVC-based terminal UI system with split-screen layout, component-based rendering, and direct method calls for simplicity.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Split-Screen Layout**: Scrollable output area on top, fixed input area at bottom
|
|
8
|
+
- **MVC Architecture**: Clean separation of concerns (Model-View-Controller)
|
|
9
|
+
- **Direct Method Calls**: Agent directly calls UIController semantic methods
|
|
10
|
+
- **Component-Based**: Reusable, composable UI components
|
|
11
|
+
- **Scrollable Output**: Navigate through history with arrow keys
|
|
12
|
+
- **Input History**: Navigate previous inputs with up/down arrows
|
|
13
|
+
- **Responsive**: Handles terminal resize automatically
|
|
14
|
+
- **Rich Formatting**: Colored output with Pastel integration
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
+---------------------------------------------+
|
|
20
|
+
| Business Layer (Agent) |
|
|
21
|
+
| Agent directly calls @ui.show_xxx() |
|
|
22
|
+
+-----------------------+---------------------+
|
|
23
|
+
| Direct calls
|
|
24
|
+
v
|
|
25
|
+
+---------------------------------------------+
|
|
26
|
+
| Controller Layer (UIController) |
|
|
27
|
+
| - show_assistant_message() |
|
|
28
|
+
| - show_tool_call() |
|
|
29
|
+
| - show_tool_result() |
|
|
30
|
+
| - request_confirmation() |
|
|
31
|
+
+-----------------------+---------------------+
|
|
32
|
+
| Render
|
|
33
|
+
v
|
|
34
|
+
+---------------------------------------------+
|
|
35
|
+
| View Layer (ViewRenderer) |
|
|
36
|
+
| - Components (Message, Tool, Status) |
|
|
37
|
+
+---------------------------------------------+
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### Agent Integration
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Create UI controller
|
|
46
|
+
ui_controller = Clacky::UI2::UIController.new(
|
|
47
|
+
working_dir: Dir.pwd,
|
|
48
|
+
mode: "confirm_safes",
|
|
49
|
+
model: "claude-3-5-sonnet"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Create agent with UI injected
|
|
53
|
+
agent = Clacky::Agent.new(client, config, ui: ui_controller)
|
|
54
|
+
|
|
55
|
+
# Set up input handler
|
|
56
|
+
ui_controller.on_input do |input, images|
|
|
57
|
+
result = agent.run(input, images: images)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Start UI (blocks until exit)
|
|
61
|
+
ui_controller.start
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### UIController Semantic Methods
|
|
65
|
+
|
|
66
|
+
The Agent calls these methods directly:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Show messages
|
|
70
|
+
@ui.show_assistant_message("Hello!")
|
|
71
|
+
@ui.show_user_message("Hi there", images: [])
|
|
72
|
+
|
|
73
|
+
# Show tool operations
|
|
74
|
+
@ui.show_tool_call("file_reader", { path: "test.rb" })
|
|
75
|
+
@ui.show_tool_result("File contents...")
|
|
76
|
+
@ui.show_tool_error("File not found")
|
|
77
|
+
|
|
78
|
+
# Show status
|
|
79
|
+
@ui.show_progress("Thinking...")
|
|
80
|
+
@ui.clear_progress
|
|
81
|
+
@ui.show_complete(iterations: 5, cost: 0.001)
|
|
82
|
+
|
|
83
|
+
# Show info/warning/error
|
|
84
|
+
@ui.show_info("Session saved")
|
|
85
|
+
@ui.show_warning("Rate limited")
|
|
86
|
+
@ui.show_error("Failed to connect")
|
|
87
|
+
|
|
88
|
+
# Interactive confirmation
|
|
89
|
+
result = @ui.request_confirmation("Allow file write?", default: true)
|
|
90
|
+
# Returns: true/false for yes/no, String for feedback, nil for cancelled
|
|
91
|
+
|
|
92
|
+
# Show diff
|
|
93
|
+
@ui.show_diff(old_content, new_content, max_lines: 50)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Components
|
|
97
|
+
|
|
98
|
+
### ViewRenderer
|
|
99
|
+
|
|
100
|
+
Unified interface for rendering all UI components.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
renderer = Clacky::UI2::ViewRenderer.new
|
|
104
|
+
|
|
105
|
+
# Render messages
|
|
106
|
+
renderer.render_user_message("Hello")
|
|
107
|
+
renderer.render_assistant_message("Hi there")
|
|
108
|
+
|
|
109
|
+
# Render tools
|
|
110
|
+
renderer.render_tool_call(
|
|
111
|
+
tool_name: "file_reader",
|
|
112
|
+
formatted_call: "file_reader(path: 'test.rb')"
|
|
113
|
+
)
|
|
114
|
+
renderer.render_tool_result(result: "Success")
|
|
115
|
+
|
|
116
|
+
# Render status
|
|
117
|
+
renderer.render_status(
|
|
118
|
+
iteration: 5,
|
|
119
|
+
cost: 0.1234,
|
|
120
|
+
tasks_completed: 3,
|
|
121
|
+
tasks_total: 10
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### OutputArea
|
|
126
|
+
|
|
127
|
+
Scrollable output buffer with automatic line wrapping.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
output = Clacky::UI2::Components::OutputArea.new(height: 20)
|
|
131
|
+
|
|
132
|
+
output.append("Line 1")
|
|
133
|
+
output.append("Line 2\nLine 3")
|
|
134
|
+
|
|
135
|
+
output.scroll_up(5)
|
|
136
|
+
output.scroll_down(2)
|
|
137
|
+
output.scroll_to_top
|
|
138
|
+
output.scroll_to_bottom
|
|
139
|
+
|
|
140
|
+
output.at_bottom? # => true/false
|
|
141
|
+
output.scroll_percentage # => 0.0 to 100.0
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### InputArea
|
|
145
|
+
|
|
146
|
+
Fixed input area with cursor support and history.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
input = Clacky::UI2::Components::InputArea.new(height: 2)
|
|
150
|
+
|
|
151
|
+
input.insert_char("H")
|
|
152
|
+
input.backspace
|
|
153
|
+
input.cursor_left
|
|
154
|
+
input.cursor_right
|
|
155
|
+
|
|
156
|
+
value = input.submit # Returns and clears input
|
|
157
|
+
input.history_prev # Navigate history
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### LayoutManager
|
|
161
|
+
|
|
162
|
+
Manages screen layout and coordinates rendering.
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
layout = Clacky::UI2::LayoutManager.new(
|
|
166
|
+
output_area: output,
|
|
167
|
+
input_area: input
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
layout.initialize_screen
|
|
171
|
+
layout.append_output("Hello")
|
|
172
|
+
layout.move_input_to_output
|
|
173
|
+
layout.scroll_output_up(5)
|
|
174
|
+
layout.cleanup_screen
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Keyboard Shortcuts
|
|
178
|
+
|
|
179
|
+
- **Enter** - Submit input
|
|
180
|
+
- **Ctrl+C** - Exit/Interrupt
|
|
181
|
+
- **Ctrl+L** - Clear output
|
|
182
|
+
- **Ctrl+U** - Clear input line
|
|
183
|
+
- **Up/Down** - Scroll output (when input empty) or navigate history
|
|
184
|
+
- **Left/Right** - Move cursor in input
|
|
185
|
+
- **Home/End** - Jump to start/end of input
|
|
186
|
+
- **Backspace** - Delete character before cursor
|
|
187
|
+
- **Delete** - Delete character at cursor
|
|
188
|
+
|
|
189
|
+
## Layout Structure
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
+----------------------------------------+
|
|
193
|
+
| Output Area (Scrollable) | <- Lines 0 to height-4
|
|
194
|
+
| [<<] Assistant: Hello... |
|
|
195
|
+
| [=>] Tool: file_reader |
|
|
196
|
+
| [<=] Result: ... |
|
|
197
|
+
| ... |
|
|
198
|
+
+----------------------------------------+ <- Separator
|
|
199
|
+
| [>>] Input: _ | <- Input line
|
|
200
|
+
+----------------------------------------+ <- Session bar
|
|
201
|
+
| Mode: confirm_safes | Tasks: 5 | $0.01 |
|
|
202
|
+
+----------------------------------------+
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Design Principles
|
|
206
|
+
|
|
207
|
+
1. **Simplicity**: Agent directly calls UIController methods - no middleware
|
|
208
|
+
2. **Dependency Injection**: Agent receives `ui:` parameter
|
|
209
|
+
3. **Component-Based**: Reusable, testable UI components
|
|
210
|
+
4. **Responsive**: Handles terminal resize and edge cases
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module UI2
|
|
7
|
+
module Components
|
|
8
|
+
# BaseComponent provides common functionality for all UI components
|
|
9
|
+
class BaseComponent
|
|
10
|
+
def initialize
|
|
11
|
+
@pastel = Pastel.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Render component with given data
|
|
15
|
+
# @param data [Hash] Data to render
|
|
16
|
+
# @return [String] Rendered output
|
|
17
|
+
def render(data)
|
|
18
|
+
raise NotImplementedError, "Subclasses must implement render method"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Class method to render without instantiating
|
|
22
|
+
# @param data [Hash] Data to render
|
|
23
|
+
# @return [String] Rendered output
|
|
24
|
+
def self.render(data)
|
|
25
|
+
new.render(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
# Get current theme from ThemeManager
|
|
31
|
+
# @return [Themes::BaseTheme] Current theme instance
|
|
32
|
+
def theme
|
|
33
|
+
UI2::ThemeManager.current_theme
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Format symbol with color from theme
|
|
37
|
+
# @param symbol_key [Symbol] Symbol key (e.g., :user, :assistant)
|
|
38
|
+
# @return [String] Colored symbol
|
|
39
|
+
def format_symbol(symbol_key)
|
|
40
|
+
theme.format_symbol(symbol_key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Format text with color from theme
|
|
44
|
+
# @param text [String] Text to format
|
|
45
|
+
# @param symbol_key [Symbol] Symbol key for color lookup
|
|
46
|
+
# @return [String] Colored text
|
|
47
|
+
def format_text(text, symbol_key)
|
|
48
|
+
theme.format_text(text, symbol_key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Truncate text to max length
|
|
52
|
+
# @param text [String] Text to truncate
|
|
53
|
+
# @param max_length [Integer] Maximum length
|
|
54
|
+
# @return [String] Truncated text
|
|
55
|
+
def truncate(text, max_length)
|
|
56
|
+
return "" if text.nil? || text.empty?
|
|
57
|
+
|
|
58
|
+
cleaned = text.strip.gsub(/\s+/, ' ')
|
|
59
|
+
|
|
60
|
+
if cleaned.length > max_length
|
|
61
|
+
cleaned[0...max_length] + "..."
|
|
62
|
+
else
|
|
63
|
+
cleaned
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Wrap text to specified width
|
|
68
|
+
# @param text [String] Text to wrap
|
|
69
|
+
# @param width [Integer] Maximum width
|
|
70
|
+
# @return [Array<String>] Array of wrapped lines
|
|
71
|
+
def wrap_text(text, width)
|
|
72
|
+
return [] if text.nil? || text.empty?
|
|
73
|
+
|
|
74
|
+
words = text.split(/\s+/)
|
|
75
|
+
lines = []
|
|
76
|
+
current_line = ""
|
|
77
|
+
|
|
78
|
+
words.each do |word|
|
|
79
|
+
if current_line.empty?
|
|
80
|
+
current_line = word
|
|
81
|
+
elsif (current_line.length + word.length + 1) <= width
|
|
82
|
+
current_line += " #{word}"
|
|
83
|
+
else
|
|
84
|
+
lines << current_line
|
|
85
|
+
current_line = word
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines << current_line unless current_line.empty?
|
|
90
|
+
lines
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Format timestamp
|
|
94
|
+
# @param time [Time] Time object
|
|
95
|
+
# @return [String] Formatted timestamp
|
|
96
|
+
def format_timestamp(time = Time.now)
|
|
97
|
+
time.strftime("%H:%M:%S")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Create indented text
|
|
101
|
+
# @param text [String] Text to indent
|
|
102
|
+
# @param spaces [Integer] Number of spaces
|
|
103
|
+
# @return [String] Indented text
|
|
104
|
+
def indent(text, spaces = 2)
|
|
105
|
+
prefix = " " * spaces
|
|
106
|
+
text.split("\n").map { |line| "#{prefix}#{line}" }.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Format key-value pair
|
|
110
|
+
# @param key [String] Key name
|
|
111
|
+
# @param value [String] Value
|
|
112
|
+
# @return [String] Formatted key-value
|
|
113
|
+
def format_key_value(key, value)
|
|
114
|
+
"#{@pastel.cyan(key)}: #{@pastel.white(value)}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Create a separator line
|
|
118
|
+
# @param char [String] Character to use
|
|
119
|
+
# @param width [Integer] Width of separator
|
|
120
|
+
# @return [String] Separator line
|
|
121
|
+
def separator(char = "─", width = 80)
|
|
122
|
+
@pastel.dim(char * width)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Format list item
|
|
126
|
+
# @param text [String] Item text
|
|
127
|
+
# @param bullet [String] Bullet character
|
|
128
|
+
# @return [String] Formatted list item
|
|
129
|
+
def format_list_item(text, bullet = "•")
|
|
130
|
+
"#{@pastel.dim(bullet)} #{@pastel.white(text)}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Format code block
|
|
134
|
+
# @param code [String] Code content
|
|
135
|
+
# @param language [String, nil] Language for syntax highlighting hint
|
|
136
|
+
# @return [String] Formatted code block
|
|
137
|
+
def format_code_block(code, language = nil)
|
|
138
|
+
header = language ? @pastel.dim("```#{language}") : @pastel.dim("```")
|
|
139
|
+
footer = @pastel.dim("```")
|
|
140
|
+
content = @pastel.cyan(code)
|
|
141
|
+
|
|
142
|
+
"#{header}\n#{content}\n#{footer}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Format progress bar
|
|
146
|
+
# @param current [Integer] Current value
|
|
147
|
+
# @param total [Integer] Total value
|
|
148
|
+
# @param width [Integer] Bar width
|
|
149
|
+
# @return [String] Progress bar
|
|
150
|
+
def format_progress_bar(current, total, width = 20)
|
|
151
|
+
return "" if total == 0
|
|
152
|
+
|
|
153
|
+
percentage = (current.to_f / total * 100).round(1)
|
|
154
|
+
filled = (current.to_f / total * width).round
|
|
155
|
+
empty = width - filled
|
|
156
|
+
|
|
157
|
+
bar = @pastel.green("█" * filled) + @pastel.dim("░" * empty)
|
|
158
|
+
"#{bar} #{percentage}%"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_component"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module UI2
|
|
7
|
+
module Components
|
|
8
|
+
# CommonComponent renders common UI elements (progress, success, error, warning)
|
|
9
|
+
class CommonComponent < BaseComponent
|
|
10
|
+
# Render thinking indicator
|
|
11
|
+
# @return [String] Thinking indicator
|
|
12
|
+
def render_thinking
|
|
13
|
+
symbol = format_symbol(:thinking)
|
|
14
|
+
text = format_text("Thinking...", :thinking)
|
|
15
|
+
"#{symbol} #{text}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Render progress indicator
|
|
19
|
+
# @param message [String] Progress message
|
|
20
|
+
# @return [String] Progress indicator
|
|
21
|
+
def render_progress(message)
|
|
22
|
+
symbol = format_symbol(:thinking)
|
|
23
|
+
text = format_text(message, :thinking)
|
|
24
|
+
"#{symbol} #{text}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Render success message
|
|
28
|
+
# @param message [String] Success message
|
|
29
|
+
# @return [String] Success message
|
|
30
|
+
def render_success(message)
|
|
31
|
+
symbol = format_symbol(:success)
|
|
32
|
+
text = format_text(message, :success)
|
|
33
|
+
"#{symbol} #{text}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Render error message
|
|
37
|
+
# @param message [String] Error message
|
|
38
|
+
# @return [String] Error message
|
|
39
|
+
def render_error(message)
|
|
40
|
+
symbol = format_symbol(:error)
|
|
41
|
+
text = format_text(message, :error)
|
|
42
|
+
"#{symbol} #{text}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Render warning message
|
|
46
|
+
# @param message [String] Warning message
|
|
47
|
+
# @return [String] Warning message
|
|
48
|
+
def render_warning(message)
|
|
49
|
+
symbol = format_symbol(:warning)
|
|
50
|
+
text = format_text(message, :warning)
|
|
51
|
+
"#{symbol} #{text}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Render task completion summary
|
|
55
|
+
# @param iterations [Integer] Number of iterations
|
|
56
|
+
# @param cost [Float] Cost in USD
|
|
57
|
+
# @param duration [Float] Duration in seconds
|
|
58
|
+
# @param cache_tokens [Integer] Cache read tokens
|
|
59
|
+
# @param cache_requests [Integer] Total cache requests count
|
|
60
|
+
# @param cache_hits [Integer] Cache hit requests count
|
|
61
|
+
# @return [String] Formatted completion summary
|
|
62
|
+
def render_task_complete(iterations:, cost:, duration: nil, cache_tokens: nil, cache_requests: nil, cache_hits: nil)
|
|
63
|
+
lines = []
|
|
64
|
+
lines << ""
|
|
65
|
+
lines << @pastel.dim("─" * 60)
|
|
66
|
+
lines << render_success("Task Complete")
|
|
67
|
+
lines << ""
|
|
68
|
+
|
|
69
|
+
# Display each stat on a separate line
|
|
70
|
+
lines << " Iterations: #{iterations}"
|
|
71
|
+
lines << " Cost: $#{cost.round(4)}"
|
|
72
|
+
lines << " Duration: #{duration.round(1)}s" if duration
|
|
73
|
+
|
|
74
|
+
# Display cache information if available
|
|
75
|
+
if cache_tokens && cache_tokens > 0
|
|
76
|
+
lines << " Cache Tokens: #{cache_tokens} tokens"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if cache_requests && cache_requests > 0
|
|
80
|
+
hit_rate = cache_hits > 0 ? ((cache_hits.to_f / cache_requests) * 100).round(1) : 0
|
|
81
|
+
lines << " Cache Requests: #{cache_requests} (#{cache_hits} hits, #{hit_rate}% hit rate)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
lines.join("\n")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|