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
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "layout_manager"
|
|
4
|
+
require_relative "view_renderer"
|
|
5
|
+
require_relative "components/output_area"
|
|
6
|
+
require_relative "components/input_area"
|
|
7
|
+
require_relative "components/todo_area"
|
|
8
|
+
require_relative "components/welcome_banner"
|
|
9
|
+
require_relative "components/inline_input"
|
|
10
|
+
require_relative "../thinking_verbs"
|
|
11
|
+
|
|
12
|
+
module Clacky
|
|
13
|
+
module UI2
|
|
14
|
+
# UIController is the MVC controller layer that coordinates UI state and user interactions
|
|
15
|
+
class UIController
|
|
16
|
+
attr_reader :layout, :renderer, :running, :inline_input, :input_area
|
|
17
|
+
attr_accessor :config
|
|
18
|
+
|
|
19
|
+
def initialize(config = {})
|
|
20
|
+
@renderer = ViewRenderer.new
|
|
21
|
+
|
|
22
|
+
# Set theme if specified
|
|
23
|
+
ThemeManager.set_theme(config[:theme]) if config[:theme]
|
|
24
|
+
|
|
25
|
+
# Store configuration
|
|
26
|
+
@config = {
|
|
27
|
+
working_dir: config[:working_dir],
|
|
28
|
+
mode: config[:mode],
|
|
29
|
+
model: config[:model],
|
|
30
|
+
theme: config[:theme]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Initialize layout components
|
|
34
|
+
@output_area = Components::OutputArea.new(height: 20) # Will be recalculated
|
|
35
|
+
@input_area = Components::InputArea.new
|
|
36
|
+
@todo_area = Components::TodoArea.new
|
|
37
|
+
@welcome_banner = Components::WelcomeBanner.new
|
|
38
|
+
@inline_input = nil # Created when needed
|
|
39
|
+
@layout = LayoutManager.new(
|
|
40
|
+
output_area: @output_area,
|
|
41
|
+
input_area: @input_area,
|
|
42
|
+
todo_area: @todo_area
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@running = false
|
|
46
|
+
@input_callback = nil
|
|
47
|
+
@interrupt_callback = nil
|
|
48
|
+
@tasks_count = 0
|
|
49
|
+
@total_cost = 0.0
|
|
50
|
+
@progress_thread = nil
|
|
51
|
+
@progress_start_time = nil
|
|
52
|
+
@progress_message = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Start the UI controller
|
|
56
|
+
def start
|
|
57
|
+
initialize_and_show_banner
|
|
58
|
+
start_input_loop
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Initialize screen and show banner (separate from input loop)
|
|
62
|
+
def initialize_and_show_banner
|
|
63
|
+
@running = true
|
|
64
|
+
|
|
65
|
+
# Set session bar data before initializing screen
|
|
66
|
+
@input_area.update_sessionbar(
|
|
67
|
+
working_dir: @config[:working_dir],
|
|
68
|
+
mode: @config[:mode],
|
|
69
|
+
model: @config[:model],
|
|
70
|
+
tasks: @tasks_count,
|
|
71
|
+
cost: @total_cost
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@layout.initialize_screen
|
|
75
|
+
|
|
76
|
+
# Display welcome banner
|
|
77
|
+
display_welcome_banner
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Start input loop (separate from initialization)
|
|
81
|
+
def start_input_loop
|
|
82
|
+
@running = true
|
|
83
|
+
input_loop
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Update session bar with current stats
|
|
87
|
+
# @param tasks [Integer] Number of completed tasks (optional)
|
|
88
|
+
# @param cost [Float] Total cost (optional)
|
|
89
|
+
# @param status [String] Workspace status ('idle' or 'working') (optional)
|
|
90
|
+
def update_sessionbar(tasks: nil, cost: nil, status: nil)
|
|
91
|
+
@tasks_count = tasks if tasks
|
|
92
|
+
@total_cost = cost if cost
|
|
93
|
+
@input_area.update_sessionbar(
|
|
94
|
+
working_dir: @config[:working_dir],
|
|
95
|
+
mode: @config[:mode],
|
|
96
|
+
model: @config[:model],
|
|
97
|
+
tasks: @tasks_count,
|
|
98
|
+
cost: @total_cost,
|
|
99
|
+
status: status
|
|
100
|
+
)
|
|
101
|
+
@layout.render_input
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Toggle permission mode between confirm_safes and auto_approve
|
|
105
|
+
def toggle_mode
|
|
106
|
+
current_mode = @config[:mode]
|
|
107
|
+
new_mode = case current_mode.to_s
|
|
108
|
+
when /confirm_safes/
|
|
109
|
+
"auto_approve"
|
|
110
|
+
when /auto_approve/
|
|
111
|
+
"confirm_safes"
|
|
112
|
+
else
|
|
113
|
+
"auto_approve" # Default to auto_approve if unknown mode
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@config[:mode] = new_mode
|
|
117
|
+
|
|
118
|
+
# Notify CLI to update agent_config
|
|
119
|
+
@mode_toggle_callback&.call(new_mode)
|
|
120
|
+
|
|
121
|
+
update_sessionbar
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Stop the UI controller
|
|
125
|
+
def stop
|
|
126
|
+
@running = false
|
|
127
|
+
@layout.cleanup_screen
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Set callback for user input
|
|
131
|
+
# @param block [Proc] Callback to execute with user input
|
|
132
|
+
def on_input(&block)
|
|
133
|
+
@input_callback = block
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Set callback for interrupt (Ctrl+C)
|
|
137
|
+
# @param block [Proc] Callback to execute on interrupt
|
|
138
|
+
def on_interrupt(&block)
|
|
139
|
+
@interrupt_callback = block
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Set callback for mode toggle (Shift+Tab)
|
|
143
|
+
# @param block [Proc] Callback to execute on mode toggle
|
|
144
|
+
def on_mode_toggle(&block)
|
|
145
|
+
@mode_toggle_callback = block
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Append output to the output area
|
|
149
|
+
# @param content [String] Content to append
|
|
150
|
+
def append_output(content)
|
|
151
|
+
@layout.append_output(content)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Log message to output area (use instead of puts)
|
|
155
|
+
# @param message [String] Message to log
|
|
156
|
+
# @param level [Symbol] Log level (:debug, :info, :warning, :error)
|
|
157
|
+
def log(message, level: :info)
|
|
158
|
+
theme = ThemeManager.current_theme
|
|
159
|
+
|
|
160
|
+
output = case level
|
|
161
|
+
when :debug
|
|
162
|
+
# Gray dimmed text for debug messages
|
|
163
|
+
theme.format_text(" [DEBUG] #{message}", :thinking)
|
|
164
|
+
when :info
|
|
165
|
+
# Info symbol with normal text
|
|
166
|
+
"#{theme.format_symbol(:info)} #{message}"
|
|
167
|
+
when :warning
|
|
168
|
+
# Warning rendering
|
|
169
|
+
@renderer.render_warning(message)
|
|
170
|
+
when :error
|
|
171
|
+
# Error rendering
|
|
172
|
+
@renderer.render_error(message)
|
|
173
|
+
else
|
|
174
|
+
# Default to info
|
|
175
|
+
"#{theme.format_symbol(:info)} #{message}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
append_output(output)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Update the last line in output area (for progress indicator)
|
|
182
|
+
# @param content [String] Content to update
|
|
183
|
+
def update_progress_line(content)
|
|
184
|
+
@layout.update_last_line(content)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Clear the progress line (remove last line)
|
|
188
|
+
def clear_progress_line
|
|
189
|
+
@layout.remove_last_line
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Update todos display
|
|
193
|
+
# @param todos [Array<Hash>] Array of todo items
|
|
194
|
+
def update_todos(todos)
|
|
195
|
+
@layout.update_todos(todos)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Display token usage statistics
|
|
199
|
+
# @param token_data [Hash] Token usage data containing:
|
|
200
|
+
# - delta_tokens: token delta from previous iteration
|
|
201
|
+
# - prompt_tokens: input tokens
|
|
202
|
+
# - completion_tokens: output tokens
|
|
203
|
+
# - total_tokens: total tokens
|
|
204
|
+
# - cache_write: cache write tokens
|
|
205
|
+
# - cache_read: cache read tokens
|
|
206
|
+
# - cost: cost for this iteration
|
|
207
|
+
def show_token_usage(token_data)
|
|
208
|
+
theme = ThemeManager.current_theme
|
|
209
|
+
|
|
210
|
+
token_info = []
|
|
211
|
+
|
|
212
|
+
# Delta tokens with color coding
|
|
213
|
+
delta_tokens = token_data[:delta_tokens]
|
|
214
|
+
delta_str = "+#{delta_tokens}"
|
|
215
|
+
colored_delta = if delta_tokens > 10000
|
|
216
|
+
theme.format_text(delta_str, :error)
|
|
217
|
+
elsif delta_tokens > 5000
|
|
218
|
+
theme.format_text(delta_str, :warning)
|
|
219
|
+
else
|
|
220
|
+
theme.format_text(delta_str, :success)
|
|
221
|
+
end
|
|
222
|
+
token_info << colored_delta
|
|
223
|
+
|
|
224
|
+
# Cache status indicator (using theme)
|
|
225
|
+
cache_write = token_data[:cache_write]
|
|
226
|
+
cache_read = token_data[:cache_read]
|
|
227
|
+
cache_used = cache_read > 0 || cache_write > 0
|
|
228
|
+
if cache_used
|
|
229
|
+
token_info << theme.format_symbol(:cached)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Input tokens (with cache breakdown if available)
|
|
233
|
+
prompt_tokens = token_data[:prompt_tokens]
|
|
234
|
+
if cache_write > 0 || cache_read > 0
|
|
235
|
+
input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
|
|
236
|
+
token_info << "Input: #{input_detail}"
|
|
237
|
+
else
|
|
238
|
+
token_info << "Input: #{prompt_tokens}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Output tokens
|
|
242
|
+
token_info << "Output: #{token_data[:completion_tokens]}"
|
|
243
|
+
|
|
244
|
+
# Total
|
|
245
|
+
token_info << "Total: #{token_data[:total_tokens]}"
|
|
246
|
+
|
|
247
|
+
# Cost for this iteration
|
|
248
|
+
if token_data[:cost]
|
|
249
|
+
token_info << "Cost: $#{token_data[:cost].round(6)}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Display through output system
|
|
253
|
+
token_display = theme.format_text(" [Tokens] #{token_info.join(' | ')}", :thinking)
|
|
254
|
+
append_output(token_display)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Show tool call arguments
|
|
258
|
+
# @param formatted_args [String] Formatted arguments string
|
|
259
|
+
def show_tool_args(formatted_args)
|
|
260
|
+
theme = ThemeManager.current_theme
|
|
261
|
+
append_output("\n#{theme.format_text("Args: #{formatted_args}", :thinking)}")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Show file operation preview (Write tool)
|
|
265
|
+
# @param path [String] File path
|
|
266
|
+
# @param is_new_file [Boolean] Whether this is a new file
|
|
267
|
+
def show_file_write_preview(path, is_new_file:)
|
|
268
|
+
theme = ThemeManager.current_theme
|
|
269
|
+
file_label = theme.format_symbol(:file)
|
|
270
|
+
status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
|
|
271
|
+
append_output("\n#{file_label} #{path || '(unknown)'}")
|
|
272
|
+
append_output(status)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Show file operation preview (Edit tool)
|
|
276
|
+
# @param path [String] File path
|
|
277
|
+
def show_file_edit_preview(path)
|
|
278
|
+
theme = ThemeManager.current_theme
|
|
279
|
+
file_label = theme.format_symbol(:file)
|
|
280
|
+
append_output("\n#{file_label} #{path || '(unknown)'}")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Show file operation error
|
|
284
|
+
# @param error_message [String] Error message
|
|
285
|
+
def show_file_error(error_message)
|
|
286
|
+
theme = ThemeManager.current_theme
|
|
287
|
+
append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Show shell command preview
|
|
291
|
+
# @param command [String] Shell command
|
|
292
|
+
def show_shell_preview(command)
|
|
293
|
+
theme = ThemeManager.current_theme
|
|
294
|
+
cmd_label = theme.format_symbol(:command)
|
|
295
|
+
append_output("\n#{cmd_label} #{command}")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# === Semantic UI Methods (for Agent to call directly) ===
|
|
299
|
+
|
|
300
|
+
# Show user message
|
|
301
|
+
# @param content [String] Message content
|
|
302
|
+
# @param images [Array] Image paths (optional)
|
|
303
|
+
def show_user_message(content, images: [])
|
|
304
|
+
output = @renderer.render_user_message(content)
|
|
305
|
+
append_output(output)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Show assistant message
|
|
309
|
+
# @param content [String] Message content
|
|
310
|
+
def show_assistant_message(content)
|
|
311
|
+
output = @renderer.render_assistant_message(content)
|
|
312
|
+
append_output(output)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Show tool call
|
|
316
|
+
# @param name [String] Tool name
|
|
317
|
+
# @param args [String, Hash] Tool arguments (JSON string or Hash)
|
|
318
|
+
def show_tool_call(name, args)
|
|
319
|
+
formatted_call = format_tool_call(name, args)
|
|
320
|
+
output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
|
|
321
|
+
append_output(output)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Show tool result
|
|
325
|
+
# @param result [String] Formatted tool result
|
|
326
|
+
def show_tool_result(result)
|
|
327
|
+
output = @renderer.render_tool_result(result: result)
|
|
328
|
+
append_output(output)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Show tool error
|
|
332
|
+
# @param error [String, Exception] Error message or exception
|
|
333
|
+
def show_tool_error(error)
|
|
334
|
+
error_msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
335
|
+
output = @renderer.render_tool_error(error: error_msg)
|
|
336
|
+
append_output(output)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Show completion status (only for tasks with more than 5 iterations)
|
|
340
|
+
# @param iterations [Integer] Number of iterations
|
|
341
|
+
# @param cost [Float] Cost of this run
|
|
342
|
+
# @param duration [Float] Duration in seconds
|
|
343
|
+
# @param cache_stats [Hash] Cache statistics
|
|
344
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil)
|
|
345
|
+
# Update status back to 'idle' when task is complete
|
|
346
|
+
update_sessionbar(status: 'idle')
|
|
347
|
+
|
|
348
|
+
# Only show completion message for complex tasks (>5 iterations)
|
|
349
|
+
return if iterations <= 5
|
|
350
|
+
|
|
351
|
+
cache_tokens = cache_stats&.dig(:cache_read_input_tokens)
|
|
352
|
+
cache_requests = cache_stats&.dig(:total_requests)
|
|
353
|
+
cache_hits = cache_stats&.dig(:cache_hit_requests)
|
|
354
|
+
|
|
355
|
+
output = @renderer.render_task_complete(
|
|
356
|
+
iterations: iterations,
|
|
357
|
+
cost: cost,
|
|
358
|
+
duration: duration,
|
|
359
|
+
cache_tokens: cache_tokens,
|
|
360
|
+
cache_requests: cache_requests,
|
|
361
|
+
cache_hits: cache_hits
|
|
362
|
+
)
|
|
363
|
+
append_output(output)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Show progress indicator with dynamic elapsed time
|
|
367
|
+
# @param message [String] Progress message (optional, will use random thinking verb if nil)
|
|
368
|
+
def show_progress(message = nil)
|
|
369
|
+
# Stop any existing progress thread
|
|
370
|
+
stop_progress_thread
|
|
371
|
+
|
|
372
|
+
# Update status to 'working'
|
|
373
|
+
update_sessionbar(status: 'working')
|
|
374
|
+
|
|
375
|
+
@progress_message = message || Clacky::THINKING_VERBS.sample
|
|
376
|
+
@progress_start_time = Time.now
|
|
377
|
+
|
|
378
|
+
# Show initial progress
|
|
379
|
+
output = @renderer.render_progress("#{@progress_message}… (ctrl+c to interrupt)")
|
|
380
|
+
append_output(output)
|
|
381
|
+
|
|
382
|
+
# Start background thread to update elapsed time
|
|
383
|
+
@progress_thread = Thread.new do
|
|
384
|
+
while @progress_start_time
|
|
385
|
+
sleep 0.5
|
|
386
|
+
next unless @progress_start_time
|
|
387
|
+
|
|
388
|
+
elapsed = (Time.now - @progress_start_time).to_i
|
|
389
|
+
update_progress_line(@renderer.render_progress("#{@progress_message}… (ctrl+c to interrupt · #{elapsed}s)"))
|
|
390
|
+
end
|
|
391
|
+
rescue => e
|
|
392
|
+
# Silently handle thread errors
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Clear progress indicator
|
|
397
|
+
def clear_progress
|
|
398
|
+
stop_progress_thread
|
|
399
|
+
clear_progress_line
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Stop the progress update thread
|
|
403
|
+
def stop_progress_thread
|
|
404
|
+
@progress_start_time = nil
|
|
405
|
+
if @progress_thread&.alive?
|
|
406
|
+
@progress_thread.kill
|
|
407
|
+
@progress_thread = nil
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Show info message
|
|
412
|
+
# @param message [String] Info message
|
|
413
|
+
def show_info(message)
|
|
414
|
+
output = @renderer.render_system_message(message)
|
|
415
|
+
append_output(output)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Show warning message
|
|
419
|
+
# @param message [String] Warning message
|
|
420
|
+
def show_warning(message)
|
|
421
|
+
output = @renderer.render_warning(message)
|
|
422
|
+
append_output(output)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Show error message
|
|
426
|
+
# @param message [String] Error message
|
|
427
|
+
def show_error(message)
|
|
428
|
+
output = @renderer.render_error(message)
|
|
429
|
+
append_output(output)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Set workspace status to idle (called when agent stops working)
|
|
433
|
+
def set_idle_status
|
|
434
|
+
update_sessionbar(status: 'idle')
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Set workspace status to working (called when agent starts working)
|
|
438
|
+
def set_working_status
|
|
439
|
+
update_sessionbar(status: 'working')
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Show help text
|
|
443
|
+
def show_help
|
|
444
|
+
theme = ThemeManager.current_theme
|
|
445
|
+
|
|
446
|
+
# Separator line
|
|
447
|
+
separator = theme.format_text("─" * 60, :info)
|
|
448
|
+
|
|
449
|
+
lines = [
|
|
450
|
+
separator,
|
|
451
|
+
"",
|
|
452
|
+
theme.format_text("Commands:", :info),
|
|
453
|
+
" #{theme.format_text("/clear", :success)} - Clear output and restart session",
|
|
454
|
+
" #{theme.format_text("/exit", :success)} - Exit application",
|
|
455
|
+
"",
|
|
456
|
+
theme.format_text("Input:", :info),
|
|
457
|
+
" #{theme.format_text("Shift+Enter", :success)} - New line",
|
|
458
|
+
" #{theme.format_text("Up/Down", :success)} - History navigation",
|
|
459
|
+
" #{theme.format_text("Ctrl+V", :success)} - Paste image (Ctrl+D to delete, max 3)",
|
|
460
|
+
" #{theme.format_text("Ctrl+C", :success)} - Clear input (press 2x to exit)",
|
|
461
|
+
"",
|
|
462
|
+
theme.format_text("Other:", :info),
|
|
463
|
+
" Supports Emacs-style shortcuts (Ctrl+A, Ctrl+E, etc.)",
|
|
464
|
+
"",
|
|
465
|
+
separator
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
lines.each { |line| append_output(line) }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Request confirmation from user (blocking)
|
|
472
|
+
# @param message [String] Confirmation prompt
|
|
473
|
+
# @param default [Boolean] Default value if user presses Enter
|
|
474
|
+
# @return [Boolean, String, nil] true/false for yes/no, String for feedback, nil for cancelled
|
|
475
|
+
def request_confirmation(message, default: true)
|
|
476
|
+
# Show question in output with theme styling
|
|
477
|
+
theme = ThemeManager.current_theme
|
|
478
|
+
question_symbol = theme.format_symbol(:info)
|
|
479
|
+
append_output("\n#{question_symbol} #{message}")
|
|
480
|
+
|
|
481
|
+
# Pause InputArea
|
|
482
|
+
@input_area.pause
|
|
483
|
+
@layout.recalculate_layout
|
|
484
|
+
|
|
485
|
+
# Create InlineInput with styled prompt
|
|
486
|
+
inline_input = Components::InlineInput.new(
|
|
487
|
+
prompt: theme.format_text(" (y/n, or provide feedback): ", :thinking),
|
|
488
|
+
default: nil
|
|
489
|
+
)
|
|
490
|
+
@inline_input = inline_input
|
|
491
|
+
|
|
492
|
+
# Add inline input line to output (use layout to track position)
|
|
493
|
+
@layout.append_output(inline_input.render)
|
|
494
|
+
@layout.position_inline_input_cursor(inline_input)
|
|
495
|
+
|
|
496
|
+
# Collect input (blocks until user presses Enter)
|
|
497
|
+
result_text = inline_input.collect
|
|
498
|
+
|
|
499
|
+
# Clean up - remove the inline input line (use layout to track position)
|
|
500
|
+
@layout.remove_last_line
|
|
501
|
+
|
|
502
|
+
# Append the final response to output
|
|
503
|
+
if result_text.nil?
|
|
504
|
+
append_output(theme.format_text(" [Cancelled]", :error))
|
|
505
|
+
else
|
|
506
|
+
display_text = result_text.empty? ? (default ? "y" : "n") : result_text
|
|
507
|
+
append_output(theme.format_text(" #{display_text}", :success))
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Deactivate and clean up
|
|
511
|
+
@inline_input = nil
|
|
512
|
+
@input_area.resume
|
|
513
|
+
@layout.recalculate_layout
|
|
514
|
+
@layout.render_all
|
|
515
|
+
|
|
516
|
+
# Parse result
|
|
517
|
+
return nil if result_text.nil? # Cancelled
|
|
518
|
+
|
|
519
|
+
response = result_text.strip.downcase
|
|
520
|
+
case response
|
|
521
|
+
when "y", "yes" then true
|
|
522
|
+
when "n", "no" then false
|
|
523
|
+
when "" then default
|
|
524
|
+
else
|
|
525
|
+
result_text # Return feedback text
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Show diff between old and new content
|
|
530
|
+
# @param old_content [String] Old content
|
|
531
|
+
# @param new_content [String] New content
|
|
532
|
+
# @param max_lines [Integer] Maximum lines to show
|
|
533
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
534
|
+
require 'diffy'
|
|
535
|
+
|
|
536
|
+
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
537
|
+
all_lines = diff.to_s(:color).lines
|
|
538
|
+
display_lines = all_lines.first(max_lines)
|
|
539
|
+
|
|
540
|
+
display_lines.each { |line| append_output(line.chomp) }
|
|
541
|
+
if all_lines.size > max_lines
|
|
542
|
+
append_output("\n... (#{all_lines.size - max_lines} more lines, diff truncated)")
|
|
543
|
+
end
|
|
544
|
+
rescue LoadError
|
|
545
|
+
# Fallback if diffy is not available
|
|
546
|
+
append_output(" Old size: #{old_content.bytesize} bytes")
|
|
547
|
+
append_output(" New size: #{new_content.bytesize} bytes")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
private
|
|
551
|
+
|
|
552
|
+
# Format tool call for display
|
|
553
|
+
# @param name [String] Tool name
|
|
554
|
+
# @param args [String, Hash] Tool arguments
|
|
555
|
+
# @return [String] Formatted call string
|
|
556
|
+
def format_tool_call(name, args)
|
|
557
|
+
args_hash = args.is_a?(String) ? JSON.parse(args, symbolize_names: true) : args
|
|
558
|
+
|
|
559
|
+
# Try to get tool instance for custom formatting
|
|
560
|
+
tool = get_tool_instance(name)
|
|
561
|
+
if tool
|
|
562
|
+
begin
|
|
563
|
+
return tool.format_call(args_hash)
|
|
564
|
+
rescue StandardError
|
|
565
|
+
# Fallback
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Simple fallback
|
|
570
|
+
"#{name}(...)"
|
|
571
|
+
rescue JSON::ParserError
|
|
572
|
+
"#{name}(...)"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Get tool instance by name
|
|
576
|
+
# @param tool_name [String] Tool name
|
|
577
|
+
# @return [Object, nil] Tool instance or nil
|
|
578
|
+
def get_tool_instance(tool_name)
|
|
579
|
+
# Convert tool_name to class name (e.g., "file_reader" -> "FileReader")
|
|
580
|
+
class_name = tool_name.split('_').map(&:capitalize).join
|
|
581
|
+
|
|
582
|
+
# Try to find the class in Clacky::Tools namespace
|
|
583
|
+
if Clacky::Tools.const_defined?(class_name)
|
|
584
|
+
tool_class = Clacky::Tools.const_get(class_name)
|
|
585
|
+
tool_class.new
|
|
586
|
+
else
|
|
587
|
+
nil
|
|
588
|
+
end
|
|
589
|
+
rescue NameError
|
|
590
|
+
nil
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Display welcome banner with logo and agent info
|
|
594
|
+
def display_welcome_banner
|
|
595
|
+
content = @welcome_banner.render_full(
|
|
596
|
+
working_dir: @config[:working_dir],
|
|
597
|
+
mode: @config[:mode]
|
|
598
|
+
)
|
|
599
|
+
append_output(content)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Main input loop
|
|
603
|
+
def input_loop
|
|
604
|
+
@layout.screen.enable_raw_mode
|
|
605
|
+
|
|
606
|
+
while @running
|
|
607
|
+
key = @layout.screen.read_key(timeout: 0.1)
|
|
608
|
+
next unless key
|
|
609
|
+
|
|
610
|
+
handle_key(key)
|
|
611
|
+
end
|
|
612
|
+
rescue => e
|
|
613
|
+
stop
|
|
614
|
+
raise e
|
|
615
|
+
ensure
|
|
616
|
+
@layout.screen.disable_raw_mode
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Handle keyboard input - delegate to InputArea or InlineInput
|
|
620
|
+
# @param key [Symbol, String, Hash] Key input or rapid input hash
|
|
621
|
+
def handle_key(key)
|
|
622
|
+
# If InlineInput is active, delegate to it
|
|
623
|
+
if @inline_input&.active?
|
|
624
|
+
handle_inline_input_key(key)
|
|
625
|
+
return
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
result = @input_area.handle_key(key)
|
|
629
|
+
|
|
630
|
+
# Handle height change first
|
|
631
|
+
if result[:height_changed]
|
|
632
|
+
@layout.recalculate_layout
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Handle actions
|
|
636
|
+
case result[:action]
|
|
637
|
+
when :submit
|
|
638
|
+
handle_submit(result[:data])
|
|
639
|
+
when :exit
|
|
640
|
+
stop
|
|
641
|
+
exit(0)
|
|
642
|
+
when :interrupt
|
|
643
|
+
# Stop progress indicator
|
|
644
|
+
stop_progress_thread
|
|
645
|
+
|
|
646
|
+
# Check if input area has content
|
|
647
|
+
input_was_empty = @input_area.empty?
|
|
648
|
+
|
|
649
|
+
# Notify CLI to handle interrupt (stop agent or exit)
|
|
650
|
+
@interrupt_callback&.call(input_was_empty: input_was_empty)
|
|
651
|
+
when :clear_output
|
|
652
|
+
# Clear the screen
|
|
653
|
+
@layout.clear_output
|
|
654
|
+
# Notify the callback to reset session/agent
|
|
655
|
+
@input_callback&.call("/clear", [])
|
|
656
|
+
when :scroll_up
|
|
657
|
+
@layout.scroll_output_up
|
|
658
|
+
when :scroll_down
|
|
659
|
+
@layout.scroll_output_down
|
|
660
|
+
when :help
|
|
661
|
+
show_help
|
|
662
|
+
@input_area.clear
|
|
663
|
+
when :toggle_mode
|
|
664
|
+
toggle_mode
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Always re-render input area after key handling
|
|
668
|
+
@layout.render_input
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Handle key input for InlineInput
|
|
672
|
+
def handle_inline_input_key(key)
|
|
673
|
+
result = @inline_input.handle_key(key)
|
|
674
|
+
|
|
675
|
+
case result[:action]
|
|
676
|
+
when :update
|
|
677
|
+
# Update the last line of output with current input (use layout to track position)
|
|
678
|
+
@layout.update_last_line(@inline_input.render)
|
|
679
|
+
# Position cursor for inline input
|
|
680
|
+
@layout.position_inline_input_cursor(@inline_input)
|
|
681
|
+
when :submit, :cancel
|
|
682
|
+
# InlineInput is done, will be cleaned up by request_confirmation
|
|
683
|
+
nil
|
|
684
|
+
when :toggle_mode
|
|
685
|
+
# Update mode and session bar info, but don't render yet
|
|
686
|
+
current_mode = @config[:mode]
|
|
687
|
+
new_mode = case current_mode.to_s
|
|
688
|
+
when /confirm_safes/
|
|
689
|
+
"auto_approve"
|
|
690
|
+
when /auto_approve/
|
|
691
|
+
"confirm_safes"
|
|
692
|
+
else
|
|
693
|
+
"auto_approve"
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
@config[:mode] = new_mode
|
|
697
|
+
@mode_toggle_callback&.call(new_mode)
|
|
698
|
+
|
|
699
|
+
# Update session bar data (will be rendered by request_confirmation's render_all)
|
|
700
|
+
@input_area.update_sessionbar(
|
|
701
|
+
working_dir: @config[:working_dir],
|
|
702
|
+
mode: @config[:mode],
|
|
703
|
+
model: @config[:model],
|
|
704
|
+
tasks: @tasks_count,
|
|
705
|
+
cost: @total_cost
|
|
706
|
+
)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Handle submit action
|
|
711
|
+
def handle_submit(data)
|
|
712
|
+
# Call callback first (allows interrupting previous agent before showing new input)
|
|
713
|
+
@input_callback&.call(data[:text], data[:images])
|
|
714
|
+
|
|
715
|
+
# Append the input content to output area after callback completes
|
|
716
|
+
@layout.append_output(data[:display]) unless data[:display].empty?
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|