openclacky 0.5.1 → 0.5.3

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.
data/lib/clacky/cli.rb CHANGED
@@ -3,7 +3,10 @@
3
3
  require "thor"
4
4
  require "tty-prompt"
5
5
  require "tty-spinner"
6
- require "readline"
6
+ require_relative "ui/banner"
7
+ require_relative "ui/prompt"
8
+ require_relative "ui/statusbar"
9
+ require_relative "ui/formatter"
7
10
 
8
11
  module Clacky
9
12
  class CLI < Thor
@@ -140,7 +143,7 @@ module Clacky
140
143
  # Report the error
141
144
  say "\n❌ Error: #{e.message}", :red
142
145
  say e.backtrace.first(5).join("\n"), :red if options[:verbose]
143
-
146
+
144
147
  # Show session saved message
145
148
  if session_manager&.last_saved_path
146
149
  say "\n📂 Session saved: #{session_manager.last_saved_path}", :yellow
@@ -149,7 +152,7 @@ module Clacky
149
152
  # Guide user to recover
150
153
  say "\n💡 To recover and retry, run:", :yellow
151
154
  say " clacky agent -c", :cyan
152
-
155
+
153
156
  exit 1
154
157
  ensure
155
158
  Dir.chdir(original_dir)
@@ -214,12 +217,14 @@ module Clacky
214
217
  end
215
218
 
216
219
  def display_agent_event(event)
220
+ formatter = ui_formatter
221
+
217
222
  case event[:type]
218
223
  when :thinking
219
- print "💭 "
224
+ formatter.thinking
220
225
  when :assistant_message
221
226
  # Display assistant's thinking/explanation before tool calls
222
- say "\n💬 #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
227
+ formatter.assistant_message(event[:data][:content])
223
228
  when :tool_call
224
229
  display_tool_call(event[:data])
225
230
  when :observation
@@ -227,15 +232,15 @@ module Clacky
227
232
  # Auto-display TODO status if exists
228
233
  display_todo_status_if_exists
229
234
  when :answer
230
- say "\n⏺ #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
235
+ formatter.assistant_message(event[:data][:content])
231
236
  when :tool_denied
232
- say "\n⏺ Tool denied: #{event[:data][:name]}\n\n", :yellow
237
+ formatter.tool_denied(event[:data][:name])
233
238
  when :tool_planned
234
- say "\n⏺ Planned: #{event[:data][:name]}", :blue
239
+ formatter.tool_planned(event[:data][:name])
235
240
  when :tool_error
236
- say "\n⏺ Error: #{event[:data][:error].message}", :red
241
+ formatter.tool_error(event[:data][:error].message)
237
242
  when :on_iteration
238
- say "\n--- Iteration #{event[:data][:iteration]} ---", :yellow if options[:verbose]
243
+ formatter.iteration(event[:data][:iteration]) if options[:verbose]
239
244
  end
240
245
  end
241
246
 
@@ -249,14 +254,14 @@ module Clacky
249
254
  begin
250
255
  args = JSON.parse(args_json, symbolize_names: true)
251
256
  formatted = tool.format_call(args)
252
- say "\n⏺ #{formatted}", :cyan
257
+ ui_formatter.tool_call(formatted)
253
258
  rescue JSON::ParserError, StandardError => e
254
259
  say "⚠️ Warning: Failed to format tool call: #{e.message}", :yellow
255
- say "\n⏺ #{tool_name}(...)", :cyan
260
+ ui_formatter.tool_call("#{tool_name}(...)")
256
261
  end
257
262
  else
258
263
  say "⚠️ Warning: Tool instance not found for '#{tool_name}'", :yellow
259
- say "\n⏺ #{tool_name}(...)", :cyan
264
+ ui_formatter.tool_call("#{tool_name}(...)")
260
265
  end
261
266
 
262
267
  # Show verbose details if requested
@@ -274,15 +279,15 @@ module Clacky
274
279
  if tool
275
280
  begin
276
281
  summary = tool.format_result(result)
277
- say " ⎿ #{summary}", :white
282
+ ui_formatter.tool_result(summary)
278
283
  rescue StandardError => e
279
- say "Done", :white
284
+ ui_formatter.tool_result("Done")
280
285
  end
281
286
  else
282
287
  # Fallback for unknown tools
283
288
  result_str = result.to_s
284
289
  summary = result_str.length > 100 ? "#{result_str[0..100]}..." : result_str
285
- say " ⎿ #{summary}", :white
290
+ ui_formatter.tool_result(summary)
286
291
  end
287
292
 
288
293
  # Show verbose details if requested
@@ -313,42 +318,7 @@ module Clacky
313
318
  todos = @current_agent.todos
314
319
  return if todos.empty?
315
320
 
316
- # Count statuses
317
- completed = todos.count { |t| t[:status] == "completed" }
318
- total = todos.size
319
-
320
- # Build progress bar
321
- progress_bar = todos.map { |t| t[:status] == "completed" ? "✓" : "○" }.join
322
-
323
- # Check if all completed
324
- if completed == total
325
- say "\n📋 Tasks [#{completed}/#{total}]: #{progress_bar} 🎉 All completed!", :green
326
- return
327
- end
328
-
329
- # Find current and next tasks
330
- current_task = todos.find { |t| t[:status] == "pending" }
331
- next_task_index = todos.index(current_task)
332
- next_task = next_task_index && todos[next_task_index + 1]
333
-
334
- say "\n📋 Tasks [#{completed}/#{total}]: #{progress_bar}", :yellow
335
- if current_task
336
- say " → Next: ##{current_task[:id]} - #{current_task[:task]}", :white
337
- end
338
- if next_task && next_task[:status] == "pending"
339
- say " ⇢ After that: ##{next_task[:id]} - #{next_task[:task]}", :white
340
- end
341
- end
342
-
343
- def display_agent_result(result)
344
- say "\n" + ("=" * 60), :cyan
345
- say "Agent Session Complete", :green
346
- say "=" * 60, :cyan
347
- say "Status: #{result[:status]}", :green
348
- say "Iterations: #{result[:iterations]}", :yellow
349
- say "Duration: #{result[:duration_seconds].round(2)}s", :yellow
350
- say "Total Cost: $#{result[:total_cost_usd]}", :yellow
351
- say "=" * 60, :cyan
321
+ ui_formatter.todo_status(todos)
352
322
  end
353
323
 
354
324
  def validate_working_directory(path)
@@ -387,26 +357,37 @@ module Clacky
387
357
  # Store agent as instance variable for access in display methods
388
358
  @current_agent = agent
389
359
 
360
+ # Initialize UI components
361
+ banner = ui_banner
362
+ prompt = ui_prompt
363
+ statusbar = ui_statusbar
364
+
365
+ # Show startup banner for new session
366
+ if agent.total_tasks == 0
367
+ banner.display_startup
368
+ end
369
+
390
370
  # Show session info if continuing
391
371
  if agent.total_tasks > 0
392
- say "Continuing session: #{agent.session_id[0..7]}", :cyan
393
- say "Created: #{Time.parse(agent.created_at).strftime('%Y-%m-%d %H:%M')}", :cyan
394
- say "Tasks completed: #{agent.total_tasks}", :cyan
395
- say "Total cost: $#{agent.total_cost.round(4)}", :cyan
396
- say ""
372
+ banner.display_session_continue(
373
+ session_id: agent.session_id[0..7],
374
+ created_at: Time.parse(agent.created_at).strftime('%Y-%m-%d %H:%M'),
375
+ tasks: agent.total_tasks,
376
+ cost: agent.total_cost.round(4)
377
+ )
397
378
 
398
379
  # Show recent conversation history
399
380
  display_recent_messages(agent.messages, limit: 5)
381
+ else
382
+ # Show welcome info for new session
383
+ banner.display_agent_welcome(
384
+ working_dir: working_dir,
385
+ mode: agent_config.permission_mode,
386
+ max_iterations: agent_config.max_iterations,
387
+ max_cost: agent_config.max_cost_usd
388
+ )
400
389
  end
401
390
 
402
- say "🤖 Starting interactive agent mode...", :green
403
- say "Working directory: #{working_dir}", :cyan
404
- say "Mode: #{agent_config.permission_mode}", :yellow
405
- say "Max iterations: #{agent_config.max_iterations} per task", :yellow
406
- say "Max cost: $#{agent_config.max_cost_usd} per task", :yellow
407
- say "\nType 'exit' or 'quit' to end the session.\n", :yellow
408
-
409
- prompt = TTY::Prompt.new
410
391
  total_tasks = agent.total_tasks
411
392
  total_cost = agent.total_cost
412
393
 
@@ -418,15 +399,26 @@ module Clacky
418
399
  unless current_message && !current_message.strip.empty?
419
400
  say "\n" if total_tasks > 0
420
401
 
421
- # Use Readline for better Unicode/CJK support
422
- current_message = Readline.readline("You: ", true)
402
+ # Show status bar before input
403
+ statusbar.display(
404
+ working_dir: working_dir,
405
+ mode: agent_config.permission_mode.to_s,
406
+ model: agent_config.model,
407
+ tasks: total_tasks,
408
+ cost: total_cost
409
+ )
410
+
411
+ # Use enhanced prompt with "You:" prefix
412
+ current_message = prompt.read_input(prefix: "You:")
423
413
 
424
414
  break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
425
415
  next if current_message.strip.empty?
416
+
417
+ # Display user's message after input
418
+ ui_formatter.user_message(current_message)
426
419
  end
427
420
 
428
421
  total_tasks += 1
429
- say "\n"
430
422
 
431
423
  begin
432
424
  result = agent.run(current_message) do |event|
@@ -441,17 +433,18 @@ module Clacky
441
433
  end
442
434
 
443
435
  # Show brief task completion
444
- say "\n" + ("-" * 60), :cyan
445
- say "✓ Task completed", :green
446
- say " Iterations: #{result[:iterations]}", :white
447
- say " Cost: $#{result[:total_cost_usd].round(4)}", :white
448
- say " Session total: #{total_tasks} tasks, $#{total_cost.round(4)}", :yellow
449
- say "-" * 60, :cyan
436
+ banner.display_task_complete(
437
+ iterations: result[:iterations],
438
+ cost: result[:total_cost_usd].round(4),
439
+ total_tasks: total_tasks,
440
+ total_cost: total_cost.round(4),
441
+ cache_stats: result[:cache_stats]
442
+ )
450
443
  rescue Clacky::AgentInterrupted
451
444
  # Save session on interruption
452
445
  if session_manager
453
446
  session_manager.save(agent.to_session_data(status: :interrupted))
454
- say "\n\n⚠️ Task interrupted by user (Ctrl+C)", :yellow
447
+ ui_formatter.warning("Task interrupted by user (Ctrl+C)")
455
448
  say "You can start a new task or type 'exit' to quit.\n", :yellow
456
449
  end
457
450
  rescue StandardError => e
@@ -461,17 +454,15 @@ module Clacky
461
454
  end
462
455
 
463
456
  # Report the error
464
- say "\n❌ Error: #{e.message}", :red
465
- say e.backtrace.first(3).join("\n"), :white if options[:verbose]
466
-
457
+ banner.display_error(e.message, details: options[:verbose] ? e.backtrace.first(3).join("\n") : nil)
458
+
467
459
  # Show session saved message
468
460
  if session_manager&.last_saved_path
469
- say "\n📂 Session saved: #{session_manager.last_saved_path}", :yellow
461
+ ui_formatter.info("Session saved: #{session_manager.last_saved_path}")
470
462
  end
471
463
 
472
464
  # Guide user to recover
473
- say "\n💡 To recover and retry, run:", :yellow
474
- say " clacky agent -c", :cyan
465
+ ui_formatter.info("To recover and retry, run: clacky agent -c")
475
466
  say "\nOr you can continue with a new task or type 'exit' to quit.", :yellow
476
467
  end
477
468
 
@@ -484,9 +475,10 @@ module Clacky
484
475
  session_manager.save(agent.to_session_data)
485
476
  end
486
477
 
487
- say "\n👋 Agent session ended", :green
488
- say "Total tasks completed: #{total_tasks}", :cyan
489
- say "Total cost: $#{total_cost.round(4)}", :cyan
478
+ banner.display_goodbye(
479
+ total_tasks: total_tasks,
480
+ total_cost: total_cost.round(4)
481
+ )
490
482
  end
491
483
 
492
484
  def list_sessions
@@ -582,21 +574,23 @@ module Clacky
582
574
  return
583
575
  end
584
576
 
585
- say "📜 Recent conversation history:\n", :yellow
586
- say "-" * 60, :white
577
+ formatter = ui_formatter
578
+ formatter.separator("")
579
+ say pastel.dim("Recent conversation history:"), :yellow
580
+ formatter.separator("─")
587
581
 
588
582
  recent.each do |msg|
589
583
  case msg[:role]
590
584
  when "user"
591
585
  content = truncate_message(msg[:content], 150)
592
- say "\n👤 You: #{content}", :cyan
586
+ say " #{pastel.blue('[>>]')} You: #{content}"
593
587
  when "assistant"
594
588
  content = truncate_message(msg[:content], 200)
595
- say "🤖 Assistant: #{content}", :green
589
+ say " #{pastel.green('[<<]')} Assistant: #{content}"
596
590
  end
597
591
  end
598
592
 
599
- say "\n" + ("-" * 60), :white
593
+ formatter.separator("")
600
594
  say ""
601
595
  end
602
596
 
@@ -612,6 +606,27 @@ module Clacky
612
606
  cleaned
613
607
  end
614
608
  end
609
+
610
+ # UI component accessors
611
+ def ui_banner
612
+ @ui_banner ||= UI::Banner.new
613
+ end
614
+
615
+ def ui_prompt
616
+ @ui_prompt ||= UI::Prompt.new
617
+ end
618
+
619
+ def ui_statusbar
620
+ @ui_statusbar ||= UI::StatusBar.new
621
+ end
622
+
623
+ def ui_formatter
624
+ @ui_formatter ||= UI::Formatter.new
625
+ end
626
+
627
+ def pastel
628
+ @pastel ||= Pastel.new
629
+ end
615
630
  end
616
631
 
617
632
  private
@@ -641,12 +656,23 @@ module Clacky
641
656
  base_url: config.base_url
642
657
  )
643
658
 
659
+ # Use TTY::Prompt for input
660
+ tty_prompt = TTY::Prompt.new(interrupt: :exit)
661
+
644
662
  loop do
645
- # Use Readline for better Unicode/CJK support
646
- message = Readline.readline("You: ", true)
663
+ # Use TTY::Prompt for better input handling
664
+ begin
665
+ message = tty_prompt.ask("You:", required: false) do |q|
666
+ q.modify :strip
667
+ end
668
+ rescue TTY::Reader::InputInterrupt
669
+ # Handle Ctrl+C
670
+ puts
671
+ break
672
+ end
647
673
 
648
- break if message.nil? || %w[exit quit].include?(message.downcase.strip)
649
- next if message.strip.empty?
674
+ break if message.nil? || %w[exit quit].include?(message&.downcase&.strip)
675
+ next if message.nil? || message.strip.empty?
650
676
 
651
677
  spinner = TTY::Spinner.new("[:spinner] Claude is thinking...", format: :dots)
652
678
  spinner.auto_spin
data/lib/clacky/client.rb CHANGED
@@ -43,7 +43,9 @@ module Clacky
43
43
  end
44
44
 
45
45
  # Send messages with function calling (tools) support
46
- def send_messages_with_tools(messages, model:, tools:, max_tokens:, verbose: false)
46
+ # Options:
47
+ # - enable_caching: Enable prompt caching for system prompt and tools (default: false)
48
+ def send_messages_with_tools(messages, model:, tools:, max_tokens:, verbose: false, enable_caching: false)
47
49
  body = {
48
50
  model: model,
49
51
  max_tokens: max_tokens,
@@ -51,7 +53,18 @@ module Clacky
51
53
  }
52
54
 
53
55
  # Add tools if provided
54
- body[:tools] = tools if tools&.any?
56
+ # For Claude API with caching: mark the last tool definition with cache_control
57
+ if tools&.any?
58
+ if enable_caching && supports_prompt_caching?(model)
59
+ # Deep clone tools to avoid modifying original
60
+ cached_tools = tools.map { |tool| deep_clone(tool) }
61
+ # Mark the last tool for caching (Claude caches from cache breakpoint to end)
62
+ cached_tools.last[:cache_control] = { type: "ephemeral" }
63
+ body[:tools] = cached_tools
64
+ else
65
+ body[:tools] = tools
66
+ end
67
+ end
55
68
 
56
69
  # Debug output
57
70
  if verbose || ENV["CLACKY_DEBUG"]
@@ -77,6 +90,31 @@ module Clacky
77
90
 
78
91
  private
79
92
 
93
+ # Check if the model supports prompt caching
94
+ # Currently only Claude 3.5 Sonnet and newer Claude models support this
95
+ def supports_prompt_caching?(model)
96
+ model_str = model.to_s.downcase
97
+ # Claude 3.5 Sonnet (20241022 and newer) supports prompt caching
98
+ # Also Claude 3.7 Sonnet and Opus models when they're released
99
+ model_str.include?("claude-3.5-sonnet") ||
100
+ model_str.include?("claude-3-7") ||
101
+ model_str.include?("claude-4")
102
+ end
103
+
104
+ # Deep clone a hash/array structure (for tool definitions)
105
+ def deep_clone(obj)
106
+ case obj
107
+ when Hash
108
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) }
109
+ when Array
110
+ obj.map { |item| deep_clone(item) }
111
+ when String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass
112
+ obj
113
+ else
114
+ obj.dup rescue obj
115
+ end
116
+ end
117
+
80
118
  def connection
81
119
  @connection ||= Faraday.new(url: @base_url) do |conn|
82
120
  conn.headers["Content-Type"] = "application/json"
@@ -117,15 +155,26 @@ module Clacky
117
155
  puts " content length: #{message["content"]&.length || 0}"
118
156
  end
119
157
 
158
+ # Parse usage with cache information
159
+ usage_data = {
160
+ prompt_tokens: usage["prompt_tokens"],
161
+ completion_tokens: usage["completion_tokens"],
162
+ total_tokens: usage["total_tokens"]
163
+ }
164
+
165
+ # Add cache metrics if present (Claude API with prompt caching)
166
+ if usage["cache_creation_input_tokens"]
167
+ usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"]
168
+ end
169
+ if usage["cache_read_input_tokens"]
170
+ usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"]
171
+ end
172
+
120
173
  {
121
174
  content: message["content"],
122
175
  tool_calls: parse_tool_calls(message["tool_calls"]),
123
176
  finish_reason: data["choices"].first["finish_reason"],
124
- usage: {
125
- prompt_tokens: usage["prompt_tokens"],
126
- completion_tokens: usage["completion_tokens"],
127
- total_tokens: usage["total_tokens"]
128
- }
177
+ usage: usage_data
129
178
  }
130
179
  when 401
131
180
  raise Error, "Invalid API key"
@@ -8,7 +8,8 @@ module Clacky
8
8
  :on_tool_error,
9
9
  :on_start,
10
10
  :on_complete,
11
- :on_iteration
11
+ :on_iteration,
12
+ :session_rollback
12
13
  ].freeze
13
14
 
14
15
  def initialize
@@ -14,7 +14,9 @@ module Clacky
14
14
  def start
15
15
  @start_time = Time.now
16
16
  @running = true
17
- print_status("#{@thinking_verb}… (ctrl+c to interrupt) ")
17
+ # Save cursor position after the [..] symbol
18
+ print "\e[s" # Save cursor position
19
+ print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt)")
18
20
 
19
21
  # Start background thread to update elapsed time
20
22
  @update_thread = Thread.new do
@@ -29,24 +31,25 @@ module Clacky
29
31
  return unless @start_time
30
32
 
31
33
  elapsed = (Time.now - @start_time).to_i
32
- print_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s) ")
34
+ print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s)")
33
35
  end
34
36
 
35
37
  def finish
36
38
  @running = false
37
39
  @update_thread&.join
38
- clear_line
40
+ # Restore cursor and clear to end of line
41
+ print "\e[u" # Restore cursor position
42
+ print "\e[K" # Clear to end of line
43
+ puts "" # Add newline after finishing
39
44
  end
40
45
 
41
46
  private
42
47
 
43
- def print_status(text)
44
- print "\r\033[K#{text}" # \r moves to start of line, \033[K clears to end of line
45
- $stdout.flush
46
- end
47
-
48
- def clear_line
49
- print "\r\033[K" # Clear the entire line
48
+ def print_thinking_status(text)
49
+ print "\e[u" # Restore cursor position (to after [..] symbol)
50
+ print "\e[K" # Clear to end of line from cursor
51
+ print text
52
+ print " "
50
53
  $stdout.flush
51
54
  end
52
55
  end
@@ -147,6 +147,12 @@ module Clacky
147
147
  trash_directory = Clacky::TrashDirectory.new(@project_root)
148
148
  @trash_dir = trash_directory.trash_dir
149
149
  @backup_dir = trash_directory.backup_dir
150
+
151
+ # Setup safety log directory under ~/.clacky/safety_logs/
152
+ @project_hash = trash_directory.generate_project_hash(@project_root)
153
+ @safety_log_dir = File.join(Dir.home, ".clacky", "safety_logs", @project_hash)
154
+ FileUtils.mkdir_p(@safety_log_dir) unless Dir.exist?(@safety_log_dir)
155
+ @safety_log_file = File.join(@safety_log_dir, "safety.log")
150
156
  end
151
157
 
152
158
  def make_command_safe(command)
@@ -384,8 +390,7 @@ module Clacky
384
390
  end
385
391
 
386
392
  def write_log(log_entry)
387
- log_file = File.join(@project_root, '.ai_safety.log')
388
- File.open(log_file, 'a') do |f|
393
+ File.open(@safety_log_file, 'a') do |f|
389
394
  f.puts JSON.generate(log_entry)
390
395
  end
391
396
  rescue StandardError
@@ -260,7 +260,7 @@ module Clacky
260
260
  - Use 'list' to see what files are in trash
261
261
  - Use 'restore' to get back accidentally deleted files
262
262
  - Use 'empty' periodically to free up disk space
263
- - All deletions by SafeShell are logged in .ai_safety.log
263
+ - All deletions by SafeShell are logged in ~/.clacky/safety_logs/
264
264
  HELP
265
265
 
266
266
  {