openclacky 0.9.30 → 0.9.32

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/lib/clacky/agent/llm_caller.rb +5 -5
  4. data/lib/clacky/agent/memory_updater.rb +1 -1
  5. data/lib/clacky/agent/session_serializer.rb +2 -1
  6. data/lib/clacky/agent/skill_auto_creator.rb +119 -0
  7. data/lib/clacky/agent/skill_evolution.rb +46 -0
  8. data/lib/clacky/agent/skill_manager.rb +8 -0
  9. data/lib/clacky/agent/skill_reflector.rb +97 -0
  10. data/lib/clacky/agent.rb +38 -12
  11. data/lib/clacky/agent_config.rb +10 -1
  12. data/lib/clacky/brand_config.rb +23 -0
  13. data/lib/clacky/cli.rb +1 -1
  14. data/lib/clacky/default_skills/onboard/SKILL.md +15 -7
  15. data/lib/clacky/default_skills/personal-website/publish.rb +1 -1
  16. data/lib/clacky/default_skills/skill-creator/SKILL.md +46 -0
  17. data/lib/clacky/json_ui_controller.rb +0 -4
  18. data/lib/clacky/message_history.rb +0 -12
  19. data/lib/clacky/plain_ui_controller.rb +19 -1
  20. data/lib/clacky/platform_http_client.rb +2 -4
  21. data/lib/clacky/providers.rb +12 -1
  22. data/lib/clacky/server/channel/channel_ui_controller.rb +0 -2
  23. data/lib/clacky/server/http_server.rb +13 -1
  24. data/lib/clacky/server/web_ui_controller.rb +55 -29
  25. data/lib/clacky/tools/shell.rb +91 -170
  26. data/lib/clacky/ui2/ui_controller.rb +100 -93
  27. data/lib/clacky/ui_interface.rb +0 -1
  28. data/lib/clacky/utils/arguments_parser.rb +5 -2
  29. data/lib/clacky/utils/limit_stack.rb +81 -13
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +247 -51
  32. data/lib/clacky/web/app.js +11 -3
  33. data/lib/clacky/web/brand.js +21 -3
  34. data/lib/clacky/web/creator.js +13 -2
  35. data/lib/clacky/web/i18n.js +41 -15
  36. data/lib/clacky/web/index.html +38 -20
  37. data/lib/clacky/web/sessions.js +256 -57
  38. data/lib/clacky/web/settings.js +32 -0
  39. data/lib/clacky/web/skills.js +61 -1
  40. metadata +4 -1
@@ -3,41 +3,10 @@
3
3
  require "tmpdir"
4
4
  require_relative "base"
5
5
  require_relative "../utils/encoding"
6
+ require_relative "../utils/limit_stack"
6
7
 
7
8
  module Clacky
8
9
  module Tools
9
- # A StringIO wrapper that scrubs invalid/undefined bytes to UTF-8 on every
10
- # write. Shell commands (via popen3) can emit bytes in any encoding
11
- # (GBK, Latin-1, binary, …). By sanitizing at the earliest possible point
12
- # we guarantee that every downstream operation — regex matching, line
13
- # splitting, JSON serialization — never sees invalid byte sequences.
14
- class EncodingSafeBuffer
15
- def initialize
16
- # Use ASCII-8BIT backing store to accept raw bytes from popen3 without
17
- # encoding conflicts. Scrubbing happens on write; the string method
18
- # re-labels the result as UTF-8 on the way out so callers (JSON.generate,
19
- # regex, etc.) always see a properly-tagged UTF-8 string.
20
- @io = StringIO.new("".b)
21
- end
22
-
23
- def write(data)
24
- return unless data && !data.empty?
25
-
26
- # Shell output arrives as binary (ASCII-8BIT) bytes. Use the shared
27
- # helper which scrubs only genuinely invalid sequences, preserving
28
- # multibyte characters (e.g. CJK). The result is written as raw bytes
29
- # into the ASCII-8BIT buffer.
30
- @io.write(Clacky::Utils::Encoding.to_utf8(data).b)
31
- end
32
-
33
- def string
34
- # Re-label the accumulated bytes as UTF-8. By this point every byte
35
- # has already been scrubbed by to_utf8 on write, so force_encoding is
36
- # safe and avoids an unnecessary copy.
37
- @io.string.force_encoding("UTF-8")
38
- end
39
- end
40
-
41
10
  class Shell < Base
42
11
  self.tool_name = "shell"
43
12
  self.tool_description = "Execute shell commands in the terminal"
@@ -90,14 +59,28 @@ module Clacky
90
59
  'go build'
91
60
  ].freeze
92
61
 
62
+ # Maximum characters per line (handles minified CSS/JS/JSON)
63
+ MAX_LINE_CHARS = 500
64
+ # Maximum total characters sent to LLM
65
+ MAX_LLM_OUTPUT_CHARS = 4_000
66
+ # Maximum lines kept in the rolling buffer
67
+ MAX_LLM_OUTPUT_LINES = 500
68
+
93
69
  def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000, on_output: nil, working_dir: nil)
94
70
  require "open3"
95
- require "stringio"
96
71
 
97
72
  soft_timeout, hard_timeout = determine_timeouts(command, soft_timeout, hard_timeout)
98
73
 
99
- stdout_buffer = EncodingSafeBuffer.new
100
- stderr_buffer = EncodingSafeBuffer.new
74
+ # OutputLimitBuffer built on LimitStack:
75
+ # - max_line_chars: truncates each individual line at write-time
76
+ # (prevents single-line minified JSON/CSS from filling the buffer)
77
+ # - max_chars: stops accepting new content once budget is exhausted
78
+ # (prevents runaway output from consuming LLM tokens)
79
+ # - max_size: rolling window keeps the LAST N lines
80
+ # (preserves the most recent/relevant output tail)
81
+ # All limits are enforced at write-time — no post-processing needed.
82
+ stdout_buffer = new_output_buffer
83
+ stderr_buffer = new_output_buffer
101
84
  soft_timeout_triggered = false
102
85
 
103
86
  # pgroup: 0 puts the child in its own process group so that Ctrl-C
@@ -138,12 +121,12 @@ module Clacky
138
121
 
139
122
  return format_timeout_result(
140
123
  command,
141
- stdout_buffer.string,
142
- stderr_buffer.string,
124
+ stdout_buffer.to_s,
125
+ stderr_buffer.to_s,
143
126
  elapsed,
144
127
  :hard_timeout,
145
128
  hard_timeout,
146
- max_output_lines
129
+ stdout_buffer.truncated? || stderr_buffer.truncated?
147
130
  )
148
131
  end
149
132
 
@@ -151,8 +134,8 @@ module Clacky
151
134
  if elapsed > soft_timeout && !soft_timeout_triggered
152
135
  soft_timeout_triggered = true
153
136
 
154
- interaction = detect_interaction(stdout_buffer.string) ||
155
- detect_interaction(stderr_buffer.string) ||
137
+ interaction = detect_interaction(stdout_buffer.to_s) ||
138
+ detect_interaction(stderr_buffer.to_s) ||
156
139
  detect_sudo_waiting(command, wait_thr)
157
140
  if interaction
158
141
  Process.kill('TERM', -wait_thr.pid) rescue nil
@@ -160,10 +143,10 @@ module Clacky
160
143
  stderr.close rescue nil
161
144
  return format_waiting_input_result(
162
145
  command,
163
- stdout_buffer.string,
164
- stderr_buffer.string,
146
+ stdout_buffer.to_s,
147
+ stderr_buffer.to_s,
165
148
  interaction,
166
- max_output_lines
149
+ stdout_buffer.truncated? || stderr_buffer.truncated?
167
150
  )
168
151
  end
169
152
  end
@@ -178,11 +161,11 @@ module Clacky
178
161
  data = io.read_nonblock(4096)
179
162
  if io == stdout
180
163
  utf8 = Clacky::Utils::Encoding.to_utf8(data)
181
- stdout_buffer.write(data)
164
+ stdout_buffer.push_lines(utf8)
182
165
  on_output.call(:stdout, utf8) if on_output
183
166
  else
184
167
  utf8 = Clacky::Utils::Encoding.to_utf8(data)
185
- stderr_buffer.write(data)
168
+ stderr_buffer.push_lines(utf8)
186
169
  on_output.call(:stderr, utf8) if on_output
187
170
  end
188
171
  rescue IO::WaitReadable, EOFError
@@ -212,7 +195,8 @@ module Clacky
212
195
  ready[0].each do |io|
213
196
  buf = io == stdout ? stdout_buffer : stderr_buffer
214
197
  begin
215
- buf.write(io.read_nonblock(4096))
198
+ chunk = io.read_nonblock(4096)
199
+ buf.push_lines(Clacky::Utils::Encoding.to_utf8(chunk))
216
200
  rescue IO::WaitReadable
217
201
  # not ready yet, keep waiting
218
202
  rescue EOFError
@@ -223,17 +207,14 @@ module Clacky
223
207
  rescue StandardError
224
208
  end
225
209
 
226
- stdout_output = stdout_buffer.string
227
- stderr_output = stderr_buffer.string
228
-
229
210
  {
230
211
  command: command,
231
- stdout: truncate_output(stdout_output, max_output_lines),
232
- stderr: truncate_output(stderr_output, max_output_lines),
212
+ stdout: stdout_buffer.to_s,
213
+ stderr: stderr_buffer.to_s,
233
214
  exit_code: wait_thr.value.exitstatus,
234
215
  success: wait_thr.value.success?,
235
216
  elapsed: Time.now - start_time,
236
- output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
217
+ output_truncated: stdout_buffer.truncated? || stderr_buffer.truncated?
237
218
  }
238
219
  ensure
239
220
  # Ensure child process group is killed when block exits
@@ -245,16 +226,16 @@ module Clacky
245
226
  end
246
227
  end
247
228
  rescue StandardError => e
248
- stdout_output = stdout_buffer.string
249
- stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
229
+ stdout_str = stdout_buffer.to_s
230
+ stderr_str = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
250
231
 
251
232
  {
252
233
  command: command,
253
- stdout: truncate_output(stdout_output, max_output_lines),
254
- stderr: truncate_output(stderr_output, max_output_lines),
234
+ stdout: stdout_str,
235
+ stderr: stderr_str,
255
236
  exit_code: -1,
256
237
  success: false,
257
- output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
238
+ output_truncated: stdout_buffer.truncated?
258
239
  }
259
240
  end
260
241
  end
@@ -396,21 +377,22 @@ module Clacky
396
377
  { type: "password", line: "[sudo] password:" }
397
378
  end
398
379
 
399
- def format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines)
380
+ def format_waiting_input_result(command, stdout, stderr, interaction, truncated)
400
381
  {
401
382
  command: command,
402
- stdout: truncate_output(stdout, max_output_lines),
403
- stderr: truncate_output(stderr, max_output_lines),
383
+ stdout: stdout,
384
+ stderr: stderr,
404
385
  exit_code: -2,
405
386
  success: false,
406
387
  state: 'WAITING_INPUT',
407
388
  interaction_type: interaction[:type],
408
- message: format_waiting_message(truncate_output(stdout, max_output_lines), interaction),
409
- output_truncated: output_truncated?(stdout, stderr, max_output_lines)
389
+ interaction: interaction,
390
+ message: format_waiting_message(interaction),
391
+ output_truncated: truncated
410
392
  }
411
393
  end
412
394
 
413
- def format_waiting_message(output, interaction)
395
+ def format_waiting_message(interaction)
414
396
  password_hint = if interaction[:type] == "password"
415
397
  <<~HINT
416
398
 
@@ -423,9 +405,10 @@ module Clacky
423
405
  HINT
424
406
  end
425
407
 
408
+ # NOTE: Do NOT embed `output` here — the caller already has stdout/stderr
409
+ # in dedicated fields. Including output here would duplicate tokens sent
410
+ # to the LLM (potentially 2×), causing unnecessarily high costs.
426
411
  <<~MSG
427
- #{output}
428
-
429
412
  #{'=' * 60}
430
413
  [Terminal State: WAITING_INPUT]
431
414
  #{'=' * 60}
@@ -441,42 +424,24 @@ module Clacky
441
424
  MSG
442
425
  end
443
426
 
444
- def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
427
+ def extract_last_line(output)
428
+ return "" if output.nil? || output.empty?
429
+ output.lines.last&.strip.to_s[0..200]
430
+ end
431
+
432
+ def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, truncated)
445
433
  {
446
434
  command: command,
447
- stdout: truncate_output(stdout, max_output_lines),
448
- stderr: truncate_output(
449
- stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
450
- max_output_lines
451
- ),
435
+ stdout: stdout,
436
+ stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
452
437
  exit_code: -1,
453
438
  success: false,
454
439
  state: 'TIMEOUT',
455
440
  timeout_type: type,
456
- output_truncated: output_truncated?(stdout, stderr, max_output_lines)
441
+ output_truncated: truncated
457
442
  }
458
443
  end
459
444
 
460
- # Truncate output to max_lines and max line length, adding a truncation notice if needed
461
- def truncate_output(output, max_lines)
462
- return output if output.nil? || output.empty?
463
-
464
- output = truncate_long_lines(output, MAX_LINE_CHARS)
465
- lines = output.lines
466
- return output if lines.length <= max_lines
467
-
468
- truncated_lines = lines.first(max_lines)
469
- truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
470
- truncated_lines.join + truncation_notice
471
- end
472
-
473
- # Check if output was truncated
474
- def output_truncated?(stdout, stderr, max_lines)
475
- stdout_lines = stdout&.lines&.length || 0
476
- stderr_lines = stderr&.lines&.length || 0
477
- stdout_lines > max_lines || stderr_lines > max_lines
478
- end
479
-
480
445
  def format_call(args)
481
446
  cmd = args[:command] || args['command'] || ''
482
447
  cmd_parts = cmd.split
@@ -499,103 +464,59 @@ module Clacky
499
464
  end
500
465
  end
501
466
 
502
- # Format result for LLM consumption - limit output size to save tokens
503
- # Maximum characters to include in LLM output
504
- MAX_LLM_OUTPUT_CHARS = 4000
505
- # Maximum characters per line before truncating (handles minified CSS/JS files)
506
- MAX_LINE_CHARS = 500
507
-
508
467
  def format_result_for_llm(result)
509
- return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
468
+ return result if result[:error]
510
469
 
511
470
  enc = Clacky::Utils::Encoding
471
+
472
+ # stdout/stderr are already size-limited by the LimitStack buffer used
473
+ # during execution (MAX_LINE_CHARS per line, MAX_LLM_OUTPUT_CHARS total,
474
+ # MAX_LLM_OUTPUT_LINES rolling window). No further truncation needed.
512
475
  stdout = enc.to_utf8(result[:stdout] || "")
513
476
  stderr = enc.to_utf8(result[:stderr] || "")
514
- exit_code = result[:exit_code] || 0
515
477
 
516
478
  compact = {
517
479
  command: enc.to_utf8(result[:command].to_s),
518
- exit_code: exit_code,
519
- success: result[:success]
480
+ exit_code: result[:exit_code] || 0,
481
+ success: result[:success],
482
+ stdout: stdout,
483
+ stderr: stderr
520
484
  }
521
485
 
522
- compact[:elapsed] = result[:elapsed] if result[:elapsed]
523
-
524
- command_name = extract_command_name(compact[:command])
525
-
526
- stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
527
- compact[:stdout] = stdout_info[:content]
528
- compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
529
-
530
- stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
531
- compact[:stderr] = stderr_info[:content]
532
- compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
533
-
534
486
  compact[:output_truncated] = true if result[:output_truncated]
487
+ compact[:elapsed] = result[:elapsed] if result[:elapsed]
535
488
 
536
- compact
537
- end
538
-
539
- # Extract command name from full command for temp file naming
540
- def extract_command_name(command)
541
- first_word = command.strip.split(/\s+/).first
542
- File.basename(first_word, ".*")
543
- end
544
-
545
- # Truncate output for LLM and optionally save full content to temp file
546
- def truncate_and_save(output, max_chars, label, command_name)
547
- return { content: "", temp_file: nil } if output.empty?
548
-
549
- output = truncate_long_lines(output, MAX_LINE_CHARS)
550
- return { content: output, temp_file: nil } if output.length <= max_chars
551
-
552
- safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
553
- temp_dir = Dir.mktmpdir
554
- temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
555
- File.write(temp_file, output)
556
-
557
- lines = output.lines
558
- return { content: output, temp_file: nil } if lines.length <= 2
559
-
560
- notice_overhead = 200
561
- available_chars = max_chars - notice_overhead
562
-
563
- first_part = []
564
- accumulated = 0
565
- lines.each do |line|
566
- break if accumulated + line.length > available_chars
567
- first_part << line
568
- accumulated += line.length
489
+ # Preserve WAITING_INPUT state fields
490
+ if result[:state] == 'WAITING_INPUT'
491
+ compact[:state] = 'WAITING_INPUT'
492
+ compact[:interaction_type] = result[:interaction_type]
493
+ interaction = result[:interaction_type] ? { type: result[:interaction_type], line: result.dig(:interaction, :line) || extract_last_line(stdout) } : { type: 'question', line: extract_last_line(stdout) }
494
+ compact[:message] = format_waiting_message(interaction)
569
495
  end
570
496
 
571
- total_lines = lines.length
572
- shown_lines = first_part.length
573
-
574
- notice = if label == "stderr"
575
- "\n... [Error output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
576
- else
577
- "\n... [Output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
497
+ # Preserve TIMEOUT state fields
498
+ if result[:state] == 'TIMEOUT'
499
+ compact[:state] = 'TIMEOUT'
500
+ compact[:timeout_type] = result[:timeout_type]
578
501
  end
579
502
 
580
- { content: first_part.join + notice, temp_file: temp_file }
503
+ compact
581
504
  end
582
505
 
583
- # Truncate individual lines that exceed max_line_chars
584
- # Useful for minified CSS/JS files where a single line can be megabytes
585
- def truncate_long_lines(output, max_line_chars)
586
- lines = output.lines
587
- return output if lines.none? { |l| l.chomp.length > max_line_chars }
588
-
589
- lines.map do |line|
590
- chopped = line.chomp
591
- if chopped.length > max_line_chars
592
- "#{chopped[0...max_line_chars]}... [line truncated: #{chopped.length} chars total]\n"
593
- else
594
- line
595
- end
596
- end.join
506
+ # Create a new output buffer with write-time limits.
507
+ # - max_line_chars: each line is truncated at this length on push
508
+ # (prevents single-line minified JSON/CSS from filling the budget)
509
+ # - max_chars: stops accepting content once total chars are exhausted
510
+ # (hard ceiling on tokens sent to LLM)
511
+ # - max_size: rolling window — keeps the LAST N lines
512
+ # (preserves the most recent, relevant tail of long output)
513
+ private def new_output_buffer
514
+ Clacky::Utils::LimitStack.new(
515
+ max_size: MAX_LLM_OUTPUT_LINES,
516
+ max_line_chars: MAX_LINE_CHARS,
517
+ max_chars: MAX_LLM_OUTPUT_CHARS
518
+ )
597
519
  end
598
-
599
520
  end
600
521
  end
601
522
  end
@@ -398,6 +398,34 @@ module Clacky
398
398
  # doesn't bleed into the next one, and so the buffer is ready before
399
399
  # on_output starts firing (which can happen before show_progress is called).
400
400
  @stdout_lines = nil
401
+
402
+ # Special handling for request_user_feedback: render as a readable interactive card
403
+ # with the full question and options, rather than the truncated format_call summary.
404
+ if name.to_s == "request_user_feedback"
405
+ args_data = args.is_a?(String) ? (JSON.parse(args, symbolize_names: true) rescue {}) : args
406
+ args_data = args_data.transform_keys(&:to_sym) if args_data.is_a?(Hash)
407
+
408
+ question = args_data[:question].to_s.strip
409
+ context = args_data[:context].to_s.strip
410
+ options = Array(args_data[:options])
411
+
412
+ theme = ThemeManager.current_theme
413
+ parts = []
414
+
415
+ parts << context unless context.empty?
416
+ parts << question unless question.empty?
417
+
418
+ if options.any?
419
+ parts << ""
420
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
421
+ end
422
+
423
+ card_text = parts.join("\n")
424
+ output = @renderer.render_system_message(card_text, prefix_newline: true)
425
+ append_output(output)
426
+ return
427
+ end
428
+
401
429
  formatted_call = format_tool_call(name, args)
402
430
  output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
403
431
  append_output(output)
@@ -465,30 +493,72 @@ module Clacky
465
493
 
466
494
  # Show progress indicator with dynamic elapsed time
467
495
  # @param message [String] Progress message (optional, will use random thinking verb if nil)
468
- # @param prefix_newline [Boolean] Whether to add a blank line before progress (default: true)
496
+ # Show or stop a progress indicator.
497
+ #
498
+ # Rendering strategies by progress_type:
499
+ # "thinking" — spinner in yellow (render_working), session bar → 'working' (default)
500
+ # "retrying" — spinner in gray (render_progress), no session bar change;
501
+ # shows "attempt/total" from metadata: { attempt: N, total: M }
502
+ # "idle_compress" — same as "retrying"; background work the user need not watch closely
503
+ #
504
+ # phase:
505
+ # "active" — start/update the progress indicator (default)
506
+ # "done" — stop progress; auto-appends elapsed time to the final line
507
+ #
508
+ # @param message [String, nil] Progress message (nil uses a random thinking verb for "thinking")
509
+ # @param prefix_newline [Boolean] Prepend blank line before new progress line (default: true)
510
+ # @param progress_type [String] "thinking" | "retrying" | "idle_compress"
511
+ # @param phase [String] "active" | "done"
512
+ # @param metadata [Hash] { attempt: N, total: M } for "retrying" type
469
513
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
470
- # Stop any existing progress thread
514
+ # ── phase "done": stop progress and render final state ──────────────────────────
515
+ if phase.to_s == "done"
516
+ elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
517
+ saved_message = @progress_message
518
+
519
+ stop_progress_thread
520
+ @stdout_lines = nil
521
+
522
+ # Prefer caller-supplied message; fall back to elapsed-time summary
523
+ final_msg = if message && !message.to_s.strip.empty?
524
+ message.to_s
525
+ elsif saved_message && elapsed_time > 0
526
+ "#{saved_message}… (#{elapsed_time}s)"
527
+ end
528
+
529
+ if final_msg
530
+ update_progress_line(@renderer.render_progress(final_msg))
531
+ else
532
+ clear_progress_line
533
+ end
534
+ return
535
+ end
536
+
537
+ # ── "active": start or update spinner ───────────────────────────────────────────
471
538
  stop_progress_thread
472
539
 
473
- # Update status to 'working'
474
- update_sessionbar(status: 'working')
540
+ # "thinking" updates session bar; "retrying"/"idle_compress" are background — leave it alone
541
+ update_sessionbar(status: 'working') if progress_type.to_s == "thinking"
542
+
543
+ # Build display message; "retrying" appends attempt/total from metadata
544
+ attempt = metadata[:attempt]
545
+ total = metadata[:total]
546
+ attempt_suffix = attempt && total ? " (#{attempt}/#{total})" : ""
475
547
 
476
- @progress_message = message || Clacky::THINKING_VERBS.sample
548
+ @progress_message = (message || Clacky::THINKING_VERBS.sample) + attempt_suffix
477
549
  @progress_start_time = Time.now
478
- # Flag used by the progress thread to know when to stop gracefully.
479
- # Using a flag + join is safe because Thread#kill can interrupt a thread
480
- # while it holds @render_mutex, causing a permanent deadlock.
481
550
  @progress_thread_stop = false
482
551
 
483
- # Show initial progress (yellow, active)
552
+ # Choose render style: yellow for thinking, gray for retrying/idle
553
+ quiet_type = %w[retrying idle_compress].include?(progress_type.to_s)
554
+ render_active = ->(msg) {
555
+ quiet_type ? @renderer.render_progress(msg) : @renderer.render_working(msg)
556
+ }
557
+
484
558
  append_output("") if prefix_newline
485
- # Initial hint — no stdout yet, so no Ctrl+O tip at this point.
486
- # The background thread will add the Ctrl+O tip as soon as any stdout arrives.
487
- output = @renderer.render_working("#{@progress_message}… (Ctrl+C to interrupt)")
488
- append_output(output)
559
+ append_output(render_active.call("#{@progress_message}… (Ctrl+C to interrupt)"))
489
560
 
490
- # Start background thread to update elapsed time.
491
- # Also dynamically adds "Ctrl+O to view output" hint once @stdout_lines is populated.
561
+ # Background thread: update elapsed time every 0.5s
492
562
  @progress_thread = Thread.new do
493
563
  until @progress_thread_stop
494
564
  sleep 0.5
@@ -502,7 +572,7 @@ module Clacky
502
572
  hint = has_output ?
503
573
  "(Ctrl+C to interrupt · Ctrl+O to view output · #{elapsed}s)" :
504
574
  "(Ctrl+C to interrupt · #{elapsed}s)"
505
- update_progress_line(@renderer.render_working("#{@progress_message}… #{hint}"))
575
+ update_progress_line(render_active.call("#{@progress_message}… #{hint}"))
506
576
  end
507
577
  rescue StandardError
508
578
  # Silently handle thread errors
@@ -510,70 +580,10 @@ module Clacky
510
580
  end
511
581
 
512
582
  # Returns true if a progress indicator is currently active.
513
- # Used to guard calls to clear_progress so we don't accidentally
514
- # remove a non-progress line from the output buffer.
515
583
  def progress_active?
516
584
  @progress_start_time != nil
517
585
  end
518
586
 
519
- # Clear progress indicator
520
- def clear_progress
521
- # Guard: if no progress is active, do nothing.
522
- # This makes clear_progress idempotent — safe to call multiple times
523
- # (e.g. once from handle_submit and again from handle_agent_exception).
524
- return unless progress_active?
525
-
526
- # Calculate elapsed time before stopping
527
- elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
528
-
529
- # Stop the progress thread (blocks until thread exits or timeout)
530
- stop_progress_thread
531
-
532
- # stdout buffer no longer needed after command finishes
533
- @stdout_lines = nil
534
-
535
- # Update the final progress line to gray (stopped state)
536
- if @progress_message && elapsed_time > 0
537
- final_output = @renderer.render_progress("#{@progress_message}… (#{elapsed_time}s)")
538
- update_progress_line(final_output)
539
- else
540
- clear_progress_line
541
- end
542
- end
543
-
544
- # Non-blocking variant of clear_progress, used by handle_submit so the
545
- # user message appears on screen immediately without waiting for the
546
- # progress thread to fully exit.
547
- #
548
- # Strategy:
549
- # 1. Snapshot elapsed time and message before touching any state.
550
- # 2. Signal the progress thread to stop (sets flag + nulls start time)
551
- # so it will exit on its own next wake-up — no join here.
552
- # 3. Immediately render the final (gray) progress line or remove it.
553
- # 4. The thread finishes in the background; clear_progress called later
554
- # via the interrupt path is idempotent and will be a no-op.
555
- def clear_progress_nonblocking
556
- return unless progress_active?
557
-
558
- elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
559
- saved_message = @progress_message
560
-
561
- # Signal thread to stop without joining — it will exit on next loop tick
562
- @progress_start_time = nil
563
- @stdout_lines = nil
564
- @progress_thread_stop = true
565
- # Detach: let the thread die on its own; we do NOT join here
566
- @progress_thread = nil
567
-
568
- # Immediately update the visual progress line (no waiting)
569
- if saved_message && elapsed_time > 0
570
- final_output = @renderer.render_progress("#{saved_message}… (#{elapsed_time}s)")
571
- update_progress_line(final_output)
572
- else
573
- clear_progress_line
574
- end
575
- end
576
-
577
587
  # Stop the fullscreen refresh thread gracefully via flag + join.
578
588
  def stop_fullscreen_refresh_thread
579
589
  @fullscreen_refresh_stop = true
@@ -609,14 +619,6 @@ module Clacky
609
619
  append_output(output)
610
620
  end
611
621
 
612
- # Show idle compression status (two-phase: start → end).
613
- # In terminal mode, only the final state is printed.
614
- def show_idle_status(phase:, message:)
615
- return unless phase.to_s == "end"
616
- output = @renderer.render_system_message(message)
617
- append_output(output)
618
- end
619
-
620
622
  # Show warning message
621
623
  # @param message [String] Warning message
622
624
  def show_warning(message)
@@ -1082,16 +1084,21 @@ module Clacky
1082
1084
 
1083
1085
  # Handle submit action
1084
1086
  private def handle_submit(data)
1085
- # If progress is currently active, clear the progress line BEFORE appending
1086
- # the user message. This avoids a race condition where clear_progress (called
1087
- # later via the interrupt path) would call update_last_line/remove_last_line
1088
- # on the user message instead of the progress line.
1089
- #
1090
- # We use a non-blocking variant here so that the progress thread's join()
1091
- # does NOT block the main thread — the user message appears on screen
1092
- # immediately with no perceptible delay.
1087
+ # If progress is currently active, stop it before appending the user message.
1088
+ # We do this non-blocking (signal thread to stop without joining) so the user
1089
+ # message appears on screen immediately with no perceptible delay.
1093
1090
  if progress_active?
1094
- clear_progress_nonblocking
1091
+ elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
1092
+ saved_message = @progress_message
1093
+ @progress_start_time = nil
1094
+ @stdout_lines = nil
1095
+ @progress_thread_stop = true
1096
+ @progress_thread = nil # detach; thread exits on its next tick
1097
+ if saved_message && elapsed_time > 0
1098
+ update_progress_line(@renderer.render_progress("#{saved_message}… (#{elapsed_time}s)"))
1099
+ else
1100
+ clear_progress_line
1101
+ end
1095
1102
  end
1096
1103
 
1097
1104
  # Render user message immediately before running agent
@@ -35,7 +35,6 @@ module Clacky
35
35
  # phase: "active" | "done"
36
36
  # metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
37
37
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
38
- def clear_progress; end
39
38
 
40
39
  # === State updates ===
41
40
  def update_sessionbar(tasks: nil, cost: nil, status: nil); end