openclacky 0.9.34 → 0.9.35

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
@@ -1,192 +1,343 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "screen_buffer"
4
- require_relative "../utils/limit_stack"
4
+ require_relative "output_buffer"
5
5
  require_relative "../utils/encoding"
6
6
 
7
7
  module Clacky
8
8
  module UI2
9
- # LayoutManager manages screen layout with split areas (output area on top, input area on bottom)
9
+ # LayoutManager coordinates the split-screen layout:
10
+ # [ scrollable output area ]
11
+ # [ gap / todo / input (fixed) ]
12
+ #
13
+ # Responsibilities:
14
+ # - Own an OutputBuffer (logical source of truth for output content).
15
+ # - Translate buffer mutations into screen paints, handling:
16
+ # * Native terminal scrolling when output overflows the output area.
17
+ # * Committing scrolled lines to the buffer (so they are never repainted
18
+ # from the buffer again — prevents the classic "double render on
19
+ # scroll up" bug).
20
+ # - Keep the fixed area (gap + todo + input) pinned at the bottom of the
21
+ # screen, repainting it only when it is dirty.
22
+ #
23
+ # Public API (id-based, preferred):
24
+ # append(content, kind: :text) -> id # add entry, returns id
25
+ # replace_entry(id, content) # edit entry's content
26
+ # remove_entry(id) # drop entry
27
+ #
28
+ # Legacy API (shims, still used by InlineInput / progress):
29
+ # append_output(content) -> id # alias for append
30
+ # update_last_line(content, old_n, id: nil) # uses id if given
31
+ # remove_last_line(n, id: nil) # uses id if given
10
32
  class LayoutManager
11
- attr_reader :screen, :input_area, :todo_area
33
+ attr_reader :screen, :input_area, :todo_area, :buffer
12
34
 
13
35
  def initialize(input_area:, todo_area: nil)
14
- @screen = ScreenBuffer.new
15
- @input_area = input_area
16
- @todo_area = todo_area
36
+ @screen = ScreenBuffer.new
37
+ @input_area = input_area
38
+ @todo_area = todo_area
39
+ @buffer = OutputBuffer.new
17
40
  @render_mutex = Mutex.new
18
- @output_row = 0 # Track current output row position
19
- @last_fixed_area_height = 0 # Track previous fixed area height to detect shrinkage
20
- @fullscreen_mode = false # Track if in fullscreen mode
21
- @resize_pending = false # Flag to indicate resize is pending
22
- @output_buffer = Utils::LimitStack.new(max_size: 500) # Buffer to store output lines with auto-rolling
41
+
42
+ @output_row = 0 # Next output row to paint into
43
+ @last_fixed_area_height = 0
44
+ @fullscreen_mode = false
45
+ @resize_pending = false
46
+
47
+ # Tracks the most recent append's id so the legacy
48
+ # update_last_line / remove_last_line shims still work without the
49
+ # caller threading an id through.
50
+ @last_append_id = nil
23
51
 
24
52
  calculate_layout
25
53
  setup_resize_handler
26
54
  end
27
55
 
28
- # Calculate layout dimensions based on screen size and component heights
56
+ # -----------------------------------------------------------------------
57
+ # Layout math
58
+ # -----------------------------------------------------------------------
59
+
29
60
  def calculate_layout
30
- todo_height = @todo_area&.height || 0
61
+ todo_height = @todo_area&.height || 0
31
62
  input_height = @input_area.required_height
32
- gap_height = 1 # Blank line between output and input
63
+ gap_height = 1
33
64
 
34
- # Layout: output -> gap -> todo -> input (with its own separators and status)
35
65
  @output_height = screen.height - gap_height - todo_height - input_height
36
- @output_height = [1, @output_height].max # Minimum 1 line for output
66
+ @output_height = [1, @output_height].max
37
67
 
38
- @gap_row = @output_height
39
- @todo_row = @gap_row + gap_height
68
+ @gap_row = @output_height
69
+ @todo_row = @gap_row + gap_height
40
70
  @input_row = @todo_row + todo_height
41
71
 
42
- # Update component dimensions
43
72
  @input_area.row = @input_row
44
73
  end
45
74
 
46
- # Recalculate layout (called when input height changes)
47
- def recalculate_layout
48
- @render_mutex.synchronize do
49
- # Save old layout values before recalculating
50
- old_gap_row = @gap_row # This is the old fixed_area_start
51
- old_input_row = @input_row
75
+ def fixed_area_height
76
+ todo_h = @todo_area&.height || 0
77
+ input_h = @input_area.required_height
78
+ 1 + todo_h + input_h
79
+ end
52
80
 
53
- calculate_layout
81
+ def fixed_area_start_row
82
+ screen.height - fixed_area_height
83
+ end
54
84
 
55
- # If layout changed, clear old fixed area and re-render at new position
56
- if @input_row != old_input_row
57
- # Clear old fixed area lines (from old gap_row to screen bottom)
58
- ([old_gap_row, 0].max...screen.height).each do |row|
59
- screen.move_cursor(row, 0)
60
- screen.clear_line
61
- end
85
+ # -----------------------------------------------------------------------
86
+ # Public output API (id-based)
87
+ # -----------------------------------------------------------------------
62
88
 
63
- # When input is paused (InlineInput active), fixed_area_start_row has grown
64
- # (input_area.required_height returns 0 while paused), so the cleared rows
65
- # now belong to the output area. Re-render output from buffer to fill them in.
66
- if input_area.paused?
67
- render_output_from_buffer
68
- else
69
- # Re-render fixed areas at new position
70
- render_fixed_areas
71
- end
72
- screen.flush
73
- end
74
- end
75
- end
89
+ # Append an output entry. Returns the entry id so callers can later
90
+ # replace_entry / remove_entry. Multi-line content is wrapped and
91
+ # stored as one logical entry.
92
+ def append(content, kind: :text)
93
+ return nil if content.nil?
94
+ content = sanitize(content)
76
95
 
77
- # Render all layout areas
78
- def render_all
79
96
  @render_mutex.synchronize do
80
- render_all_internal
81
- end
82
- end
97
+ lines = wrap_content_to_lines(content)
98
+ id = @buffer.append(lines, kind: kind)
99
+ @last_append_id = id
83
100
 
84
- # Render output area - with native scroll, just ensure input stays in place
85
- def render_output
86
- @render_mutex.synchronize do
87
- # Output is written directly, just need to re-render fixed areas
101
+ paint_new_lines(lines) unless @fullscreen_mode
88
102
  render_fixed_areas
89
103
  screen.flush
104
+ id
90
105
  end
91
106
  end
92
107
 
93
- # Render just the input area
94
- def render_input
108
+ # Legacy: append, return id (callers that ignore it still work).
109
+ def append_output(content)
110
+ append(content)
111
+ end
112
+
113
+ # Replace an existing entry's content. The screen is updated in place
114
+ # if the entry still lives in the output area; otherwise (committed
115
+ # to scrollback) this is a silent no-op.
116
+ def replace_entry(id, content)
117
+ return if id.nil? || content.nil?
118
+ content = sanitize(content)
119
+
95
120
  @render_mutex.synchronize do
96
- # Clear and re-render entire fixed area to ensure consistency
121
+ entry = @buffer.entry_by_id(id)
122
+ return if entry.nil? || entry.committed
123
+
124
+ old_lines = entry.lines.dup
125
+ new_lines = wrap_content_to_lines(content)
126
+ @buffer.replace(id, new_lines)
127
+
128
+ repaint_entry_in_place(entry, old_lines, new_lines) unless @fullscreen_mode
97
129
  render_fixed_areas
98
130
  screen.flush
99
131
  end
100
132
  end
101
133
 
102
- # Re-render everything from scratch (useful after modal dialogs)
103
- def rerender_all
104
- @render_mutex.synchronize do
105
- # Clear entire screen
106
- screen.clear_screen
134
+ # Is this id still a live (not yet committed to scrollback) entry?
135
+ # Cheap probe callers use before deciding between replace vs append.
136
+ def live_entry?(id)
137
+ return false if id.nil?
138
+ @buffer.live?(id)
139
+ end
107
140
 
108
- # Re-render output from buffer
109
- render_output_from_buffer
141
+ # Remove an entry. If it's the last live entry, the screen area it
142
+ # occupied is cleared and the output cursor rolls back.
143
+ def remove_entry(id)
144
+ return if id.nil?
145
+
146
+ @render_mutex.synchronize do
147
+ entry = @buffer.entry_by_id(id)
148
+ return if entry.nil? || entry.committed
149
+
150
+ height = entry.height
151
+ # Check whether this entry is the tail of live entries. Only tail
152
+ # removal is cheap — mid-buffer removal would require a full
153
+ # output repaint. In practice only the progress / inline-input
154
+ # entries are removed, and they are always the tail.
155
+ is_tail = @buffer.live_entries.last&.id == id
156
+
157
+ @buffer.remove(id)
158
+ @last_append_id = nil if @last_append_id == id
159
+
160
+ unless @fullscreen_mode
161
+ if is_tail
162
+ clear_tail_rows(height)
163
+ else
164
+ # Non-tail removal: rebuild the entire output area from buffer
165
+ render_output_from_buffer
166
+ end
167
+ end
110
168
 
111
- # Re-render fixed areas at new positions
112
169
  render_fixed_areas
113
170
  screen.flush
114
171
  end
115
172
  end
116
173
 
117
- # Render output area from buffer (clears and re-renders last N lines)
118
- private def render_output_from_buffer
119
- max_output_row = fixed_area_start_row
174
+ # -----------------------------------------------------------------------
175
+ # Legacy shims (kept for InlineInput + other callers that don't carry ids)
176
+ # -----------------------------------------------------------------------
120
177
 
121
- # Clear output area
122
- (0...max_output_row).each do |row|
123
- screen.move_cursor(row, 0)
124
- screen.clear_line
125
- end
178
+ # Update the most recently appended entry. Prefer passing +id:+; when
179
+ # omitted the last-append id is used. +old_line_count+ is ignored
180
+ # (buffer knows the true height).
181
+ def update_last_line(content, old_line_count = nil, id: nil)
182
+ target = id || @last_append_id
183
+ replace_entry(target, content) if target
184
+ end
126
185
 
127
- # Re-render from buffer (show last N lines that fit)
128
- @output_row = 0
129
- visible_lines = [@output_buffer.size, max_output_row].min
186
+ # Remove the most recently appended entry (or the given id).
187
+ def remove_last_line(line_count = 1, id: nil)
188
+ target = id || @last_append_id
189
+ remove_entry(target) if target
190
+ end
191
+
192
+ # -----------------------------------------------------------------------
193
+ # Paint primitives (private)
194
+ # -----------------------------------------------------------------------
195
+
196
+ # Paint fresh lines into the output area, scrolling via native \n when
197
+ # we reach the fixed area. CRUCIAL INVARIANT: every time we scroll,
198
+ # we tell the buffer "N oldest live lines just moved into scrollback"
199
+ # so they are NEVER re-painted from the buffer again. This is what
200
+ # eliminates the double-render bug.
201
+ private def paint_new_lines(lines)
202
+ max_output_row = fixed_area_start_row
203
+
204
+ lines.each do |line|
205
+ if @output_row >= max_output_row
206
+ # Scroll the terminal by emitting a real \n at the very bottom.
207
+ # That pushes the top visible row into the native scrollback
208
+ # buffer — exactly where the user will see it on scroll-up.
209
+ screen.move_cursor(screen.height - 1, 0)
210
+ print "\n"
211
+
212
+ # Tell the buffer one line of live content just left the screen.
213
+ # Committed entries become untouchable, so a later full repaint
214
+ # (resize, fixed-area height change, fullscreen exit) will NOT
215
+ # re-emit them and duplicate them in scrollback.
216
+ @buffer.commit_oldest_lines(1)
217
+
218
+ @output_row = max_output_row - 1
219
+
220
+ # The fixed area got scrolled up too — restore it. Don't trigger
221
+ # an output rebuild; the buffer's tail hasn't changed.
222
+ render_fixed_areas(skip_buffer_rerender: true)
223
+ end
130
224
 
131
- @output_buffer.last(visible_lines).each do |line|
132
225
  screen.move_cursor(@output_row, 0)
226
+ screen.clear_line
133
227
  print line
134
228
  @output_row += 1
135
229
  end
136
230
  end
137
231
 
138
- # Position cursor for inline input in output area
139
- # @param inline_input [Components::InlineInput] InlineInput component
140
- def position_inline_input_cursor(inline_input)
141
- return unless inline_input
232
+ # Repaint a single entry in place after its content changed.
233
+ # Handles both grow and shrink. If the new content would overflow
234
+ # into the fixed area, we scroll up to make room (same rules as
235
+ # paint_new_lines — scrolled rows get committed to scrollback).
236
+ private def repaint_entry_in_place(entry, old_lines, new_lines)
237
+ old_n = old_lines.length
238
+ new_n = new_lines.length
239
+ return if @output_row == 0
142
240
 
143
- # Use InlineInput's method to calculate cursor position (handles continuation prompt correctly)
144
- width = screen.width
145
- wrap_row, wrap_col = inline_input.cursor_position_for_display(width)
241
+ start_row = @output_row - old_n
242
+ start_row = 0 if start_row < 0
146
243
 
147
- # Get the number of lines InlineInput occupies (considering wrapping)
148
- line_count = inline_input.line_count(width)
244
+ max_output_row = fixed_area_start_row
149
245
 
150
- # InlineInput starts at @output_row - line_count
151
- # Cursor is at wrap_row within that
152
- cursor_row = @output_row - line_count + wrap_row
153
- cursor_col = wrap_col
246
+ # Grow + would overflow scroll first
247
+ if new_n > old_n
248
+ needed_end = start_row + new_n
249
+ if needed_end > max_output_row
250
+ overflow = needed_end - max_output_row
251
+ overflow.times do
252
+ screen.move_cursor(screen.height - 1, 0)
253
+ print "\n"
254
+ @buffer.commit_oldest_lines(1)
255
+ end
256
+ start_row -= overflow
257
+ start_row = 0 if start_row < 0
258
+ @output_row = [start_row + old_n, max_output_row].min
259
+ render_fixed_areas(skip_buffer_rerender: true)
260
+ end
261
+ end
154
262
 
155
- # Move terminal cursor to the correct position
156
- screen.move_cursor(cursor_row, cursor_col)
157
- screen.flush
263
+ # Clear the rows the entry currently occupies
264
+ (start_row...@output_row).each do |row|
265
+ screen.move_cursor(row, 0)
266
+ screen.clear_line
267
+ end
268
+
269
+ # Paint the new content
270
+ cur = start_row
271
+ new_lines.each do |line|
272
+ screen.move_cursor(cur, 0)
273
+ print line
274
+ cur += 1
275
+ end
276
+ @output_row = start_row + new_n
277
+
278
+ # If content shrank, extra rows below may still hold the old content
279
+ # if they were outside the cleared range — but since we cleared the
280
+ # full old span above, nothing extra is needed here.
158
281
  end
159
282
 
160
- # Update todos and re-render
161
- # @param todos [Array<Hash>] Array of todo items
162
- def update_todos(todos)
163
- return unless @todo_area
283
+ # Clear the last N rows of the output area (used by remove_entry on tail).
284
+ private def clear_tail_rows(n)
285
+ return if n <= 0 || @output_row == 0
164
286
 
165
- @render_mutex.synchronize do
166
- old_height = @todo_area.height
167
- old_gap_row = @gap_row
287
+ start_row = @output_row - n
288
+ start_row = 0 if start_row < 0
168
289
 
169
- @todo_area.update(todos)
170
- new_height = @todo_area.height
290
+ (start_row...@output_row).each do |row|
291
+ screen.move_cursor(row, 0)
292
+ screen.clear_line
293
+ end
294
+ @output_row = start_row
295
+ end
171
296
 
172
- # Recalculate layout if height changed
173
- if old_height != new_height
174
- calculate_layout
297
+ # Repaint the entire output area from the buffer's live entries.
298
+ # Only called on layout changes (resize, fixed-area height change,
299
+ # /clear, fullscreen exit) — never on a normal append path.
300
+ private def render_output_from_buffer
301
+ max_output_row = fixed_area_start_row
175
302
 
176
- # Clear old fixed area lines (from old gap_row to screen bottom)
177
- ([old_gap_row, 0].max...screen.height).each do |row|
178
- screen.move_cursor(row, 0)
179
- screen.clear_line
180
- end
181
- end
303
+ # Wipe the output area
304
+ (0...max_output_row).each do |row|
305
+ screen.move_cursor(row, 0)
306
+ screen.clear_line
307
+ end
182
308
 
183
- # Render fixed areas at new position
184
- render_fixed_areas
185
- screen.flush
309
+ # Fill from the buffer's tail (live lines only — committed lines
310
+ # are already in terminal scrollback and MUST NOT be repainted).
311
+ lines = @buffer.tail_lines(max_output_row)
312
+ @output_row = 0
313
+ lines.each do |line|
314
+ screen.move_cursor(@output_row, 0)
315
+ print line
316
+ @output_row += 1
186
317
  end
187
318
  end
188
319
 
189
- # Initialize the screen (render initial content)
320
+ # Wrap user content into screen-width visual lines using the existing
321
+ # ANSI-aware helper. Guarantees at least one line (possibly empty).
322
+ private def wrap_content_to_lines(content)
323
+ raw_lines = content.split("\n", -1)
324
+ wrapped = []
325
+ raw_lines.each do |rl|
326
+ wrapped.concat(wrap_long_line(rl))
327
+ end
328
+ wrapped = [""] if wrapped.empty?
329
+ wrapped
330
+ end
331
+
332
+ private def sanitize(content)
333
+ return content if content.valid_encoding?
334
+ Clacky::Utils::Encoding.sanitize_utf8(content)
335
+ end
336
+
337
+ # -----------------------------------------------------------------------
338
+ # Lifecycle + layout
339
+ # -----------------------------------------------------------------------
340
+
190
341
  def initialize_screen
191
342
  screen.clear_screen
192
343
  screen.hide_cursor
@@ -194,288 +345,198 @@ module Clacky
194
345
  render_all
195
346
  end
196
347
 
197
- # Cleanup the screen (restore cursor)
198
348
  def cleanup_screen
199
349
  @render_mutex.synchronize do
200
- # Clear fixed areas (gap + todo + input)
201
350
  fixed_start = fixed_area_start_row
202
351
  (fixed_start...screen.height).each do |row|
203
352
  screen.move_cursor(row, 0)
204
353
  screen.clear_line
205
354
  end
206
-
207
- # Move cursor to start of a new line after last output
208
- # Use \r to ensure we're at column 0, then move down
209
355
  screen.move_cursor([@output_row, 0].max, 0)
210
- print "\r" # Carriage return to column 0
356
+ print "\r"
211
357
  screen.show_cursor
212
358
  screen.flush
213
359
  end
214
360
  end
215
361
 
216
- # Clear output area (for /clear command)
362
+ # /clear: wipe output area + buffer, keep fixed area.
217
363
  def clear_output
218
364
  @render_mutex.synchronize do
219
- # Clear all lines in output area (from 0 to where fixed area starts)
220
365
  max_row = fixed_area_start_row
221
366
  (0...max_row).each do |row|
222
367
  screen.move_cursor(row, 0)
223
368
  screen.clear_line
224
369
  end
225
-
226
- # Reset output position to beginning
227
- @output_row = 0
228
-
229
- # Clear the output buffer so re-renders don't restore old content
230
- @output_buffer.clear
231
-
232
- # Re-render fixed areas to ensure they stay in place
370
+ @output_row = 0
371
+ @last_append_id = nil
372
+ @buffer.clear
233
373
  render_fixed_areas
234
374
  screen.flush
235
375
  end
236
376
  end
237
377
 
238
- # Append content to output area
239
- # This is the main output method - handles scrolling and fixed area preservation
240
- # @param content [String] Content to append (can be multi-line)
241
- def append_output(content)
242
- return if content.nil?
243
-
244
- # Scrub any invalid byte sequences before they reach the render pipeline.
245
- # wrap_long_line calls each_char which raises ArgumentError on invalid UTF-8.
246
- content = Clacky::Utils::Encoding.sanitize_utf8(content) unless content.valid_encoding?
247
-
378
+ # Recalculate layout after input height changed. If the layout moved,
379
+ # clear the old fixed area rows and re-render at the new position.
380
+ def recalculate_layout
248
381
  @render_mutex.synchronize do
249
- lines = content.split("\n", -1) # -1 to keep trailing empty strings
382
+ old_gap_row = @gap_row
383
+ old_input_row = @input_row
250
384
 
251
- lines.each_with_index do |line, index|
252
- # Wrap long lines to prevent display issues
253
- wrapped_lines = wrap_long_line(line)
385
+ calculate_layout
254
386
 
255
- wrapped_lines.each do |wrapped_line|
256
- write_output_line(wrapped_line)
387
+ if @input_row != old_input_row
388
+ ([old_gap_row, 0].max...screen.height).each do |row|
389
+ screen.move_cursor(row, 0)
390
+ screen.clear_line
257
391
  end
392
+
393
+ if input_area.paused?
394
+ # Input paused (InlineInput active) — fixed area shrank, so the
395
+ # cleared rows are now part of the output area. Repaint from
396
+ # buffer to fill them in.
397
+ render_output_from_buffer
398
+ else
399
+ render_fixed_areas
400
+ end
401
+ screen.flush
258
402
  end
403
+ end
404
+ end
405
+
406
+ def render_all
407
+ @render_mutex.synchronize { render_all_internal }
408
+ end
259
409
 
260
- # Re-render fixed areas to ensure they stay at bottom
410
+ def render_output
411
+ @render_mutex.synchronize do
261
412
  render_fixed_areas
262
413
  screen.flush
263
414
  end
264
415
  end
265
416
 
266
- # Update the last N lines in output area (for inline input updates)
267
- # @param content [String] Content to update (may contain newlines for wrapped lines)
268
- # @param old_line_count [Integer] Number of lines currently occupied (for clearing)
269
- def update_last_line(content, old_line_count = 1)
417
+ def render_input
270
418
  @render_mutex.synchronize do
271
- # Fullscreen owns the alternate screen; skip main-screen updates
272
- return if @fullscreen_mode
273
-
274
- return if @output_row == 0 # No output yet
275
-
276
- lines = content.split("\n", -1)
277
- new_line_count = lines.length
278
-
279
- # Calculate start row (last N lines)
280
- start_row = @output_row - old_line_count
281
- start_row = 0 if start_row < 0
282
-
283
- # If lines grew, check if we would overflow into the fixed area and scroll if needed
284
- if new_line_count > old_line_count
285
- max_output_row = fixed_area_start_row
286
- needed_end_row = start_row + new_line_count
287
-
288
- if needed_end_row > max_output_row
289
- # Calculate how many extra rows we need
290
- overflow = needed_end_row - max_output_row
291
-
292
- # Scroll the terminal by printing newlines at the bottom of the output area
293
- overflow.times do
294
- screen.move_cursor(screen.height - 1, 0)
295
- print "\n"
296
- end
419
+ render_fixed_areas
420
+ screen.flush
421
+ end
422
+ end
297
423
 
298
- # Adjust start_row and output_row upward after scroll
299
- start_row -= overflow
300
- start_row = 0 if start_row < 0
301
- @output_row = [start_row + old_line_count, max_output_row].min
424
+ def rerender_all
425
+ @render_mutex.synchronize do
426
+ screen.clear_screen
427
+ render_output_from_buffer
428
+ render_fixed_areas
429
+ screen.flush
430
+ end
431
+ end
302
432
 
303
- # Re-render fixed areas after scroll to prevent corruption.
304
- # Skip buffer re-render to avoid duplicating content in scrollback.
305
- render_fixed_areas(skip_buffer_rerender: true)
306
- end
307
- end
433
+ # Restore cursor to input area (used after dialogs).
434
+ def restore_cursor_to_input
435
+ input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
436
+ input_area.position_cursor(input_row)
437
+ screen.show_cursor
438
+ end
308
439
 
309
- # Clear all lines that will be updated
310
- (start_row...@output_row).each do |row|
311
- screen.move_cursor(row, 0)
312
- screen.clear_line
313
- end
440
+ # Position cursor for inline input in output area.
441
+ def position_inline_input_cursor(inline_input)
442
+ return unless inline_input
443
+ width = screen.width
444
+ wrap_row, wrap_col = inline_input.cursor_position_for_display(width)
445
+ line_count = inline_input.line_count(width)
314
446
 
315
- # Remove old lines from buffer
316
- old_line_count.times do
317
- @output_buffer.pop if @output_buffer.size > 0
318
- end
447
+ cursor_row = @output_row - line_count + wrap_row
448
+ cursor_col = wrap_col
449
+ screen.move_cursor(cursor_row, cursor_col)
450
+ screen.flush
451
+ end
319
452
 
320
- # Re-render the content
321
- current_row = start_row
453
+ # Update todos display; recalculates layout if height changed.
454
+ def update_todos(todos)
455
+ return unless @todo_area
322
456
 
323
- lines.each do |line|
324
- screen.move_cursor(current_row, 0)
325
- print line
326
- # Add updated line to buffer
327
- @output_buffer << line
328
- current_row += 1
329
- end
457
+ @render_mutex.synchronize do
458
+ old_height = @todo_area.height
459
+ old_gap_row = @gap_row
330
460
 
331
- # Update output_row to new line count
332
- @output_row = start_row + new_line_count
461
+ @todo_area.update(todos)
462
+ new_height = @todo_area.height
333
463
 
334
- # Clear any remaining old lines if new content has fewer lines
335
- # This handles the case where content shrinks (e.g., delete from 2 lines to 1 line)
336
- old_end_row = @output_row + (old_line_count - new_line_count)
337
- if old_end_row > @output_row && old_end_row <= start_row + old_line_count
338
- # Clear the extra old lines
339
- (@output_row...old_end_row).each do |row|
464
+ if old_height != new_height
465
+ calculate_layout
466
+ ([old_gap_row, 0].max...screen.height).each do |row|
340
467
  screen.move_cursor(row, 0)
341
468
  screen.clear_line
342
469
  end
343
470
  end
344
471
 
345
- # Re-render fixed areas to restore cursor position in input area.
346
- # Skip buffer re-render: the content was written directly above, so
347
- # re-rendering from buffer would duplicate it in the terminal scrollback.
348
- render_fixed_areas(skip_buffer_rerender: true)
472
+ render_fixed_areas
349
473
  screen.flush
350
474
  end
351
475
  end
352
476
 
353
- # Remove the last N lines from output area
354
- # @param line_count [Integer] Number of lines to remove (default: 1)
355
- def remove_last_line(line_count = 1)
356
- @render_mutex.synchronize do
357
- # Fullscreen owns the alternate screen; skip main-screen updates
358
- return if @fullscreen_mode
359
-
360
- return if @output_row == 0 # No output to remove
361
-
362
- # Calculate start row for removal
363
- start_row = @output_row - line_count
364
- start_row = 0 if start_row < 0
365
477
 
366
- # Clear all lines being removed
367
- (start_row...@output_row).each do |row|
368
- screen.move_cursor(row, 0)
369
- screen.clear_line
370
- end
371
478
 
372
- # Also remove from output buffer to prevent re-rendering
373
- line_count.times do
374
- @output_buffer.pop if @output_buffer.size > 0
375
- end
479
+ # -----------------------------------------------------------------------
480
+ # Fixed area (gap + todo + input) rendering
481
+ # -----------------------------------------------------------------------
376
482
 
377
- # Update output_row
378
- @output_row = start_row
483
+ # Repaint gap + todo + input at the bottom of the screen.
484
+ #
485
+ # @param skip_buffer_rerender [Boolean] When true, skip repainting the
486
+ # output area from the buffer even if the fixed-area height changed.
487
+ # Used by the scroll path in paint_new_lines — the caller has just
488
+ # written the correct content directly; a full buffer repaint would
489
+ # duplicate it in terminal scrollback.
490
+ def render_fixed_areas(skip_buffer_rerender: false)
491
+ # When input is paused (InlineInput active), the "input area" is
492
+ # rendered inline with output. Nothing to paint down here.
493
+ return if input_area.paused?
494
+ return if @fullscreen_mode
379
495
 
380
- # Re-render fixed areas to ensure consistency.
381
- # Skip buffer re-render: lines were removed both from screen and buffer above,
382
- # re-rendering would push stale content back into the terminal scrollback.
383
- render_fixed_areas(skip_buffer_rerender: true)
384
- screen.flush
496
+ current_fixed_height = fixed_area_height
497
+ start_row = fixed_area_start_row
498
+ gap_row = start_row
499
+ todo_row = gap_row + 1
500
+
501
+ # Fixed-area height changed (e.g. multi-line input appeared or
502
+ # command-suggestions popped) → repaint the output from buffer so
503
+ # nothing is hidden.
504
+ if !skip_buffer_rerender &&
505
+ @last_fixed_area_height > 0 &&
506
+ @last_fixed_area_height != current_fixed_height
507
+ render_output_from_buffer
385
508
  end
386
- end
387
-
388
- # Scroll output area up (legacy no-op)
389
- # @param lines [Integer] Number of lines to scroll
390
- def scroll_output_up(lines = 1)
391
- # No-op - terminal handles scrolling natively
392
- end
393
-
394
- # Scroll output area down (legacy no-op)
395
- # @param lines [Integer] Number of lines to scroll
396
- def scroll_output_down(lines = 1)
397
- # No-op - terminal handles scrolling natively
398
- end
399
-
400
- # Handle window resize
401
- private def handle_resize
402
- # Record old dimensions before updating to detect shrink vs grow
403
- old_height = screen.height
404
- old_width = screen.width
405
-
406
- # Update terminal dimensions and recalculate layout
407
- screen.update_dimensions
408
- calculate_layout
409
-
410
- # When shrinking: full reset (clears scrollback too), otherwise just clear current screen
411
- shrinking = screen.height < old_height || screen.width < old_width
412
- screen.clear_screen(mode: shrinking ? :reset : :current)
509
+ @last_fixed_area_height = current_fixed_height
413
510
 
414
- # Re-render all output from buffer
415
- @output_row = 0
416
- max_output_row = fixed_area_start_row
511
+ # gap line
512
+ screen.move_cursor(gap_row, 0)
513
+ screen.clear_line
417
514
 
418
- # Calculate how many lines we can show from the end of buffer
419
- visible_lines = [@output_buffer.size, max_output_row].min
515
+ # todo
516
+ @todo_area.render(start_row: todo_row) if @todo_area&.visible?
420
517
 
421
- # Render the last N lines that fit in the output area
422
- @output_buffer.last(visible_lines).each do |line|
423
- screen.move_cursor(@output_row, 0)
424
- print line
425
- @output_row += 1
426
- end
427
-
428
- # Sync @last_fixed_area_height so render_fixed_areas won't think the height
429
- # changed and trigger a second render_output_from_buffer call
430
- @last_fixed_area_height = fixed_area_height
518
+ # input (renders its own visual cursor)
519
+ input_row = todo_row + (@todo_area&.height || 0)
520
+ input_area.render(start_row: input_row, width: screen.width)
521
+ end
431
522
 
432
- # Re-render fixed areas at new positions
523
+ private def render_all_internal
433
524
  render_fixed_areas
434
525
  screen.flush
435
526
  end
436
527
 
437
- # Write a single line to output area
438
- # Handles scrolling when reaching fixed area
439
- # @param line [String] Single line to write (should not contain newlines)
440
- def write_output_line(line)
441
- # Add to buffer so content is available when returning from fullscreen
442
- @output_buffer << line
528
+ # Legacy no-ops terminal handles native scroll natively.
529
+ def scroll_output_up(_lines = 1); end
530
+ def scroll_output_down(_lines = 1); end
443
531
 
444
- # Fullscreen owns the alternate screen; skip rendering to avoid corruption
445
- return if @fullscreen_mode
446
-
447
- # Calculate where fixed area starts (this is where output area ends)
448
- max_output_row = fixed_area_start_row
449
532
 
450
- # If we're about to write into the fixed area, scroll first
451
- if @output_row >= max_output_row
452
- # Trigger terminal scroll by printing newline at bottom
453
- screen.move_cursor(screen.height - 1, 0)
454
- print "\n"
455
533
 
456
- # After scroll, position to write at the last row of output area
457
- @output_row = max_output_row - 1
534
+ # -----------------------------------------------------------------------
535
+ # Wrapping helpers (ANSI-aware, East-Asian-width aware)
536
+ # -----------------------------------------------------------------------
458
537
 
459
- # Re-render fixed areas after scroll to prevent corruption.
460
- # Skip buffer re-render: new line hasn't been drawn yet; re-rendering from
461
- # buffer would put a duplicate into the scrollback (the \n above already
462
- # pushed previous content up, so a full buffer repaint here would repeat it).
463
- render_fixed_areas(skip_buffer_rerender: true)
464
- end
465
-
466
- # Now write the line at current position
467
- screen.move_cursor(@output_row, 0)
468
- screen.clear_line
469
- print line
470
-
471
- # Move to next row for next write
472
- @output_row += 1
473
- end
474
-
475
- # Wrap a long line into multiple lines based on terminal width
476
- # Considers display width of multi-byte characters (e.g., Chinese characters)
477
- # @param line [String] Line to wrap
478
- # @return [Array<String>] Array of wrapped lines
538
+ # Wrap a long line into multiple lines based on terminal width.
539
+ # Considers display width of multi-byte characters (e.g., Chinese characters).
479
540
  def wrap_long_line(line)
480
541
  return [""] if line.nil? || line.empty?
481
542
 
@@ -485,55 +546,40 @@ module Clacky
485
546
  # Strip ANSI codes for width calculation
486
547
  visible_line = line.gsub(/\e\[[0-9;]*m/, '')
487
548
 
488
- # Check if line needs wrapping
489
549
  display_width = calculate_display_width(visible_line)
490
550
  return [line] if display_width <= max_width
491
551
 
492
- # Line needs wrapping - split by considering display width
493
- wrapped = []
552
+ wrapped = []
494
553
  current_line = ""
495
554
  current_width = 0
496
- ansi_codes = [] # Track ANSI codes to carry over
555
+ ansi_codes = []
497
556
 
498
- # Extract ANSI codes and text segments
499
557
  segments = line.split(/(\e\[[0-9;]*m)/)
500
558
 
501
559
  segments.each do |segment|
502
560
  if segment =~ /^\e\[[0-9;]*m$/
503
- # ANSI code - add to current codes
504
561
  ansi_codes << segment
505
562
  current_line += segment
506
563
  else
507
- # Text segment - process character by character
508
564
  segment.each_char do |char|
509
565
  char_width = char_display_width(char)
510
-
511
566
  if current_width + char_width > max_width && !current_line.empty?
512
- # Complete current line
513
567
  wrapped << current_line
514
- # Start new line with carried-over ANSI codes
515
568
  current_line = ansi_codes.join
516
569
  current_width = 0
517
570
  end
518
-
519
571
  current_line += char
520
572
  current_width += char_width
521
573
  end
522
574
  end
523
575
  end
524
576
 
525
- # Add remaining content
526
577
  wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join
527
-
528
578
  wrapped.empty? ? [""] : wrapped
529
579
  end
530
580
 
531
- # Calculate display width of a single character
532
- # @param char [String] Single character
533
- # @return [Integer] Display width (1 or 2)
534
581
  def char_display_width(char)
535
582
  code = char.ord
536
- # East Asian Wide and Fullwidth characters take 2 columns
537
583
  if (code >= 0x1100 && code <= 0x115F) ||
538
584
  (code >= 0x2329 && code <= 0x232A) ||
539
585
  (code >= 0x2E80 && code <= 0x303E) ||
@@ -553,221 +599,125 @@ module Clacky
553
599
  end
554
600
  end
555
601
 
556
- # Calculate display width of a string (considering multi-byte characters)
557
- # @param text [String] Text to calculate
558
- # @return [Integer] Display width
559
602
  def calculate_display_width(text)
560
603
  width = 0
561
- text.each_char do |char|
562
- width += char_display_width(char)
563
- end
604
+ text.each_char { |c| width += char_display_width(c) }
564
605
  width
565
606
  end
566
607
 
567
- # Calculate fixed area height (gap + todo + input)
568
- def fixed_area_height
569
- todo_height = @todo_area&.height || 0
570
- input_height = @input_area.required_height
571
- 1 + todo_height + input_height # gap + todo + input
572
- end
608
+ # -----------------------------------------------------------------------
609
+ # Resize handling
610
+ # -----------------------------------------------------------------------
573
611
 
574
- # Calculate the starting row for fixed areas (from screen bottom)
575
- def fixed_area_start_row
576
- screen.height - fixed_area_height
577
- end
578
-
579
- # Render fixed areas (gap, todo, input) at screen bottom
580
- # @param skip_buffer_rerender [Boolean] When true, skip the render_output_from_buffer
581
- # even if fixed_area_height changed. Use this when the caller has already written
582
- # the correct content directly (update_last_line, remove_last_line) to avoid
583
- # re-rendering buffer content that would duplicate entries in the terminal scrollback.
584
- def render_fixed_areas(skip_buffer_rerender: false)
585
- # When input is paused (InlineInput active), don't render fixed areas
586
- # The InlineInput is rendered inline with output
587
- return if input_area.paused?
588
-
589
- # Do not corrupt the alternate screen while in fullscreen mode
590
- return if @fullscreen_mode
591
-
592
- current_fixed_height = fixed_area_height
593
- start_row = fixed_area_start_row
594
- gap_row = start_row
595
- todo_row = gap_row + 1
596
- input_row = todo_row + (@todo_area&.height || 0)
597
-
598
- # Detect height changes and re-render output area if needed.
599
- # Skip when the caller (update_last_line / remove_last_line) has already
600
- # written the correct content directly — re-rendering would push duplicate
601
- # content into the terminal scrollback, causing repeated lines on scroll-up.
602
- if !skip_buffer_rerender && @last_fixed_area_height > 0 && @last_fixed_area_height != current_fixed_height
603
- # Fixed area height changed - re-render output area from buffer
604
- # This prevents output content from being hidden when fixed area grows
605
- # (e.g., multi-line input, command suggestions appearing)
606
- render_output_from_buffer
607
- end
608
-
609
- # Update last height for next comparison
610
- @last_fixed_area_height = current_fixed_height
612
+ private def handle_resize
613
+ old_height = screen.height
614
+ old_width = screen.width
611
615
 
612
- # Render gap line
613
- screen.move_cursor(gap_row, 0)
614
- screen.clear_line
616
+ screen.update_dimensions
617
+ calculate_layout
615
618
 
616
- # Render todo
617
- if @todo_area&.visible?
618
- @todo_area.render(start_row: todo_row)
619
- end
619
+ shrinking = screen.height < old_height || screen.width < old_width
620
+ screen.clear_screen(mode: shrinking ? :reset : :current)
620
621
 
621
- # Render input (InputArea renders its own visual cursor via render_line_with_cursor)
622
- input_area.render(start_row: input_row, width: screen.width)
623
- end
622
+ # Repaint from buffer only live (uncommitted) lines, which is
623
+ # exactly what we want: committed content already sits in the
624
+ # native scrollback above.
625
+ render_output_from_buffer
624
626
 
625
- # Internal render all (without mutex)
626
- def render_all_internal
627
- # Output flows naturally, just render fixed areas
627
+ # Sync so render_fixed_areas won't think height changed and
628
+ # trigger a second repaint.
629
+ @last_fixed_area_height = fixed_area_height
628
630
  render_fixed_areas
629
631
  screen.flush
630
632
  end
631
633
 
632
- # Restore cursor to input area
633
- def restore_cursor_to_input
634
- input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
635
- input_area.position_cursor(input_row)
636
- screen.show_cursor
634
+ private def setup_resize_handler
635
+ Signal.trap("WINCH") { @resize_pending = true }
636
+ rescue ArgumentError => e
637
+ warn "WINCH signal already trapped: #{e.message}"
637
638
  end
638
639
 
639
- # Restore screen from fullscreen mode (re-render everything)
640
- def restore_screen
641
- @render_mutex.synchronize do
642
- screen.clear_screen
643
- screen.hide_cursor
644
- render_all_internal
645
- end
640
+ def process_pending_resize
641
+ return unless @resize_pending
642
+ @resize_pending = false
643
+ handle_resize_safely
646
644
  end
647
645
 
648
- # Check if in fullscreen mode
649
- # @return [Boolean]
646
+ private def handle_resize_safely
647
+ @render_mutex.synchronize { handle_resize }
648
+ rescue => e
649
+ warn "Resize error: #{e.message}"
650
+ warn e.backtrace.first(5).join("\n") if e.backtrace
651
+ end
652
+
653
+ # -----------------------------------------------------------------------
654
+ # Fullscreen (alternate screen buffer)
655
+ # -----------------------------------------------------------------------
656
+
650
657
  def fullscreen_mode?
651
658
  @fullscreen_mode
652
659
  end
653
660
 
654
- # Enter fullscreen mode with alternate screen buffer
655
- # @param lines [Array<String>] Lines to display
656
- # @param hint [String] Hint message at bottom
657
661
  def enter_fullscreen(lines, hint: "Press Ctrl+O to return")
658
662
  @render_mutex.synchronize do
659
663
  return if @fullscreen_mode
660
-
661
664
  @fullscreen_mode = true
662
665
  @fullscreen_hint = hint
663
666
 
664
- # Enter alternate screen buffer and do a full clean:
665
- # \e[?1049h - switch to alternate screen buffer (separate from primary)
666
- # \e[2J - erase the entire visible screen
667
- # \e[H - move cursor to top-left
668
- # The alternate screen buffer has no scrollback history by design, so
669
- # there is nothing to scroll up to once we clear the visible area.
667
+ # Switch to alternate screen, clear it, position top-left.
670
668
  print "\e[?1049h\e[2J\e[H"
671
669
  $stdout.flush
672
-
673
670
  render_fullscreen_content(lines)
674
671
  end
675
672
  end
676
673
 
677
- # Refresh fullscreen content in-place (for real-time updates without re-entering alt screen)
678
- # @param lines [Array<String>] Updated lines to display
679
674
  def refresh_fullscreen(lines)
680
675
  @render_mutex.synchronize do
681
676
  return unless @fullscreen_mode
682
-
683
- # Move cursor to top-left and erase visible area, then redraw
684
677
  print "\e[2J\e[H"
685
678
  render_fullscreen_content(lines)
686
679
  end
687
680
  end
688
681
 
689
- # Exit fullscreen mode and restore previous screen
690
682
  def exit_fullscreen
691
683
  @render_mutex.synchronize do
692
684
  return unless @fullscreen_mode
693
-
694
685
  @fullscreen_mode = false
695
686
  @fullscreen_hint = nil
696
-
697
- # Exit alternate screen buffer (automatically restores previous screen content)
698
687
  print "\e[?1049l"
699
688
  $stdout.flush
700
689
  end
701
690
  end
702
691
 
703
- # Render lines to the alternate screen (called by enter_fullscreen / refresh_fullscreen)
704
- # Fills the entire screen: content at top, hint pinned at the very bottom row.
705
- # This prevents the terminal from showing any blank scrollable area above the hint.
706
- # @param lines [Array<String>] Lines to render
692
+ def restore_screen
693
+ @render_mutex.synchronize do
694
+ screen.clear_screen
695
+ screen.hide_cursor
696
+ render_all_internal
697
+ end
698
+ end
699
+
707
700
  private def render_fullscreen_content(lines)
708
701
  term_height = screen.height
709
702
  term_width = screen.width
710
703
 
711
- # Reserve the bottom row for the hint bar
712
- content_rows = term_height - 1
713
-
714
- # Trim or pad lines to exactly fill the content area
704
+ content_rows = term_height - 1
715
705
  display_lines = lines.first(content_rows)
716
706
 
717
- # Print each content line, padded with spaces to full terminal width so
718
- # no stale characters from a previous render remain on the right side.
719
707
  display_lines.each do |line|
720
- # Strip trailing whitespace then pad to terminal width (ignoring ANSI codes for width calc)
721
708
  visible = line.chomp.gsub(/\e\[[0-9;]*m/, "")
722
709
  padding = [term_width - visible.length, 0].max
723
710
  print line.chomp + (" " * padding) + "\r\n"
724
711
  end
725
712
 
726
- # Fill any remaining content rows with blank lines so nothing from a
727
- # previous render bleeds through when content shrinks.
728
713
  blank_row = " " * term_width
729
- (display_lines.length...content_rows).each do
730
- print blank_row + "\r\n"
731
- end
714
+ (display_lines.length...content_rows).each { print blank_row + "\r\n" }
732
715
 
733
- # Pin the hint bar at the very bottom row using absolute cursor positioning.
734
- # \e[{row};{col}H moves to the given 1-based row/col.
735
716
  hint_text = "\e[36m#{@fullscreen_hint}\e[0m"
736
717
  print "\e[#{term_height};1H#{hint_text}\e[0K"
737
-
738
718
  $stdout.flush
739
719
  end
740
720
 
741
- # Setup handler for window resize
742
- # Note: Signal handlers run in trap context where many operations are restricted
743
- private def setup_resize_handler
744
- Signal.trap("WINCH") do
745
- # Simply set a flag - actual resize handling happens in main thread
746
- @resize_pending = true
747
- end
748
- rescue ArgumentError => e
749
- # Signal already trapped (shouldn't happen now)
750
- warn "WINCH signal already trapped: #{e.message}"
751
- end
752
-
753
- # Check and process pending resize (should be called from main thread periodically)
754
- def process_pending_resize
755
- return unless @resize_pending
756
-
757
- @resize_pending = false
758
- handle_resize_safely
759
- end
760
-
761
- # Thread-safe wrapper for handle_resize
762
- private def handle_resize_safely
763
- @render_mutex.synchronize do
764
- handle_resize
765
- end
766
- rescue => e
767
- # Catch and log errors to prevent resize from crashing the app
768
- warn "Resize error: #{e.message}"
769
- warn e.backtrace.first(5).join("\n") if e.backtrace
770
- end
771
721
  end
772
722
  end
773
723
  end