openclacky 0.8.2 → 0.8.4

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.
@@ -15,6 +15,10 @@ module Clacky
15
15
  @buffer = []
16
16
  @last_input_time = nil
17
17
  @rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
18
+
19
+ # Keep stdin in UTF-8 mode so getc returns complete multi-byte characters (e.g. CJK).
20
+ # Switching to BINARY would cause getc to return one byte at a time, breaking Chinese input.
21
+ $stdin.set_encoding('UTF-8')
18
22
  end
19
23
 
20
24
  # Move cursor to specific position (0-indexed)
@@ -138,8 +142,6 @@ module Clacky
138
142
  # @param timeout [Float] Timeout in seconds
139
143
  # @return [Symbol, String, Hash, nil] Key symbol, character, or { type: :rapid_input, text: String }
140
144
  def read_key(timeout: nil)
141
- $stdin.set_encoding('UTF-8')
142
-
143
145
  current_time = Time.now.to_f
144
146
  is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
145
147
  @last_input_time = current_time
@@ -147,8 +149,9 @@ module Clacky
147
149
  char = read_char(timeout: timeout)
148
150
  return nil unless char
149
151
 
150
- # Ensure character is UTF-8 encoded
151
- char = char.force_encoding('UTF-8') if char.is_a?(String) && char.encoding != Encoding::UTF_8
152
+ # Convert raw BINARY bytes to valid UTF-8. Invalid/undefined bytes are dropped
153
+ # rather than raising ArgumentError (which would crash the input loop).
154
+ char = safe_to_utf8(char) if char.is_a?(String)
152
155
 
153
156
  # Handle escape sequences for special keys
154
157
  if char == "\e"
@@ -179,7 +182,6 @@ module Clacky
179
182
  # If this is rapid input or there are more characters available
180
183
  if is_rapid_input || has_more_input
181
184
  buffer = char.to_s.dup
182
- buffer.force_encoding('UTF-8')
183
185
 
184
186
  # Keep reading available characters
185
187
  loop_count = 0
@@ -190,10 +192,10 @@ module Clacky
190
192
  has_data = IO.select([$stdin], nil, nil, 0)
191
193
 
192
194
  if has_data
193
- next_char = $stdin.getc
195
+ next_char = $stdin.getc rescue nil
194
196
  break unless next_char
195
197
 
196
- next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
198
+ next_char = safe_to_utf8(next_char)
197
199
  buffer << next_char
198
200
  loop_count += 1
199
201
  empty_checks = 0 # Reset empty check counter
@@ -213,6 +215,8 @@ module Clacky
213
215
 
214
216
  # If we buffered multiple characters or newlines, treat as rapid input (paste)
215
217
  if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
218
+ # Ensure the accumulated buffer is valid UTF-8 before regex operations
219
+ buffer = safe_to_utf8(buffer)
216
220
  # Remove any trailing \r or \n from rapid input buffer
217
221
  cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
218
222
  return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
@@ -251,6 +255,19 @@ module Clacky
251
255
  end
252
256
 
253
257
  private
258
+
259
+ # Ensure a string is valid UTF-8.
260
+ # stdin stays in UTF-8 mode so getc returns complete characters (including CJK).
261
+ # This method handles the rare case where an invalid byte slips through
262
+ # (e.g. a stray terminal escape or a partial sequence) by scrubbing it out
263
+ # rather than letting ArgumentError crash the input loop.
264
+ # @param str [String] String from getc (UTF-8 encoded, but may have invalid bytes)
265
+ # @return [String] Valid UTF-8 string
266
+ private def safe_to_utf8(str)
267
+ return str if str.valid_encoding?
268
+
269
+ str.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: '')
270
+ end
254
271
  end
255
272
  end
256
273
  end
@@ -90,6 +90,13 @@ module Clacky
90
90
  input_loop
91
91
  end
92
92
 
93
+ # Set skill loader for command suggestions in the input area
94
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
95
+ # @param agent_profile [Clacky::AgentProfile, nil] Current agent profile for skill filtering
96
+ def set_skill_loader(skill_loader, agent_profile = nil)
97
+ @input_area.set_skill_loader(skill_loader, agent_profile)
98
+ end
99
+
93
100
  # Update session bar with current stats
94
101
  # @param tasks [Integer] Number of completed tasks (optional)
95
102
  # @param cost [Float] Total cost (optional)
@@ -170,11 +177,11 @@ module Clacky
170
177
  @time_machine_callback = block
171
178
  end
172
179
 
173
- # Set skill loader for command suggestions
174
- # @param skill_loader [Clacky::SkillLoader] The skill loader instance
180
+ # Set agent for command suggestions
181
+ # @param agent [Clacky::Agent] The agent instance with skill management
175
182
  # @param agent_profile [Clacky::AgentProfile, nil] Current agent profile for skill filtering
176
- def set_skill_loader(skill_loader, agent_profile = nil)
177
- @input_area.set_skill_loader(skill_loader, agent_profile)
183
+ def set_agent(agent, agent_profile = nil)
184
+ @input_area.set_agent(agent, agent_profile)
178
185
  end
179
186
 
180
187
  # Append output to the output area
@@ -450,6 +457,10 @@ module Clacky
450
457
  @progress_message = message || Clacky::THINKING_VERBS.sample
451
458
  @progress_start_time = Time.now
452
459
  @progress_output_buffer = output_buffer
460
+ # Flag used by the progress thread to know when to stop gracefully.
461
+ # Using a flag + join is safe because Thread#kill can interrupt a thread
462
+ # while it holds @render_mutex, causing a permanent deadlock.
463
+ @progress_thread_stop = false
453
464
 
454
465
  # Show initial progress (yellow, active)
455
466
  append_output("") if prefix_newline
@@ -459,15 +470,19 @@ module Clacky
459
470
 
460
471
  # Start background thread to update elapsed time
461
472
  @progress_thread = Thread.new do
462
- while @progress_start_time
473
+ until @progress_thread_stop
463
474
  sleep 0.5
464
- next unless @progress_start_time
475
+ next if @progress_thread_stop
476
+
477
+ start = @progress_start_time
478
+ next unless start
465
479
 
466
- elapsed = (Time.now - @progress_start_time).to_i
467
- hint = output_buffer ? "(Ctrl+C to interrupt · Ctrl+O to view output · #{elapsed}s)" : "(Ctrl+C to interrupt · #{elapsed}s)"
480
+ elapsed = (Time.now - start).to_i
481
+ buf = @progress_output_buffer
482
+ hint = buf ? "(Ctrl+C to interrupt · Ctrl+O to view output · #{elapsed}s)" : "(Ctrl+C to interrupt · #{elapsed}s)"
468
483
  update_progress_line(@renderer.render_working("#{@progress_message}… #{hint}"))
469
484
  end
470
- rescue => e
485
+ rescue StandardError
471
486
  # Silently handle thread errors
472
487
  end
473
488
  end
@@ -489,14 +504,32 @@ module Clacky
489
504
  end
490
505
  end
491
506
 
492
- # Stop the progress update thread
507
+ # Stop the fullscreen refresh thread gracefully via flag + join.
508
+ def stop_fullscreen_refresh_thread
509
+ @fullscreen_refresh_stop = true
510
+ if @fullscreen_refresh_thread&.alive?
511
+ joined = @fullscreen_refresh_thread.join(1.0)
512
+ @fullscreen_refresh_thread.kill unless joined
513
+ end
514
+ @fullscreen_refresh_thread = nil
515
+ @fullscreen_refresh_stop = false
516
+ end
517
+
518
+ # Stop the progress update thread gracefully.
519
+ # We signal the thread via a stop flag and then join it, avoiding Thread#kill
520
+ # which can interrupt a thread mid-critical-section (e.g. while holding
521
+ # @render_mutex) and leave the mutex permanently locked.
493
522
  def stop_progress_thread
494
523
  @progress_start_time = nil
495
- @progress_output_buffer = nil # Clear output buffer reference
524
+ @progress_output_buffer = nil
525
+ @progress_thread_stop = true
496
526
  if @progress_thread&.alive?
497
- @progress_thread.kill
498
- @progress_thread = nil
527
+ # Join with a short timeout; fall back to kill only as a last resort
528
+ joined = @progress_thread.join(1.0)
529
+ @progress_thread.kill unless joined
499
530
  end
531
+ @progress_thread = nil
532
+ @progress_thread_stop = false
500
533
  end
501
534
 
502
535
  # Show info message
@@ -703,12 +736,15 @@ module Clacky
703
736
  lines = build_command_output_lines
704
737
  @layout.enter_fullscreen(lines, hint: "Press Ctrl+O to return · Output updates in real-time")
705
738
 
706
- # Start background thread to refresh fullscreen content in real-time
739
+ # Start background thread to refresh fullscreen content in real-time.
740
+ # Use a dedicated stop flag so we can join() the thread cleanly and
741
+ # avoid Thread#kill interrupting the thread while it holds @render_mutex.
707
742
  buffer_ref = @progress_output_buffer
743
+ @fullscreen_refresh_stop = false
708
744
  @fullscreen_refresh_thread = Thread.new do
709
- while @layout.fullscreen_mode?
745
+ until @fullscreen_refresh_stop || !@layout.fullscreen_mode?
710
746
  sleep 0.3
711
- break unless @layout.fullscreen_mode?
747
+ next if @fullscreen_refresh_stop || !@layout.fullscreen_mode?
712
748
 
713
749
  updated_lines = build_command_output_lines_from(buffer_ref)
714
750
  @layout.refresh_fullscreen(updated_lines)
@@ -863,9 +899,10 @@ module Clacky
863
899
  # If in fullscreen mode, only handle Ctrl+O to exit
864
900
  if @layout.fullscreen_mode?
865
901
  if key == :ctrl_o
866
- # Kill the real-time refresh thread before exiting fullscreen
867
- @fullscreen_refresh_thread&.kill
868
- @fullscreen_refresh_thread = nil
902
+ # Signal the real-time refresh thread to stop gracefully, then join it.
903
+ # Avoid Thread#kill which can interrupt the thread mid-render and
904
+ # leave @render_mutex permanently locked.
905
+ stop_fullscreen_refresh_thread
869
906
  @layout.exit_fullscreen
870
907
  # Restore main screen content after returning from alternate buffer
871
908
  @layout.rerender_all
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.8.2"
4
+ VERSION = "0.8.4"
5
5
  end