openclacky 0.6.1 → 0.6.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.
@@ -6,15 +6,15 @@ module Clacky
6
6
  module UI2
7
7
  # LayoutManager manages screen layout with split areas (output area on top, input area on bottom)
8
8
  class LayoutManager
9
- attr_reader :screen, :output_area, :input_area, :todo_area
9
+ attr_reader :screen, :input_area, :todo_area
10
10
 
11
- def initialize(output_area:, input_area:, todo_area: nil)
11
+ def initialize(input_area:, todo_area: nil)
12
12
  @screen = ScreenBuffer.new
13
- @output_area = output_area
14
13
  @input_area = input_area
15
14
  @todo_area = todo_area
16
15
  @render_mutex = Mutex.new
17
16
  @output_row = 0 # Track current output row position
17
+ @last_fixed_area_height = 0 # Track previous fixed area height to detect shrinkage
18
18
 
19
19
  calculate_layout
20
20
  setup_resize_handler
@@ -35,7 +35,6 @@ module Clacky
35
35
  @input_row = @todo_row + todo_height
36
36
 
37
37
  # Update component dimensions
38
- @output_area.height = @output_height
39
38
  @input_area.row = @input_row
40
39
  end
41
40
 
@@ -93,10 +92,21 @@ module Clacky
93
92
  def position_inline_input_cursor(inline_input)
94
93
  return unless inline_input
95
94
 
96
- # InlineInput renders its own visual cursor via render_line_with_cursor
97
- # (white background on cursor character), so we don't need terminal cursor.
98
- # Just hide the terminal cursor to avoid showing two cursors.
99
- screen.hide_cursor
95
+ # Use the shared method from LineEditor to calculate cursor position with wrap
96
+ prompt = inline_input.prompt
97
+ width = screen.width
98
+ wrap_row, wrap_col = inline_input.cursor_position_with_wrap(prompt, width)
99
+
100
+ # Get the number of lines InlineInput occupies (considering wrapping)
101
+ line_count = inline_input.line_count(width)
102
+
103
+ # InlineInput starts at @output_row - line_count
104
+ # Cursor is at wrap_row within that
105
+ cursor_row = @output_row - line_count + wrap_row
106
+ cursor_col = wrap_col
107
+
108
+ # Move terminal cursor to the correct position
109
+ screen.move_cursor(cursor_row, cursor_col)
100
110
  screen.flush
101
111
  end
102
112
 
@@ -159,14 +169,16 @@ module Clacky
159
169
  # Clear output area (for /clear command)
160
170
  def clear_output
161
171
  @render_mutex.synchronize do
162
- # Clear all lines in output area (from 0 to fixed_area_start - 1)
163
- max_output_row = fixed_area_start_row
164
- (0...max_output_row).each do |row|
172
+ # Clear all lines in output area (from 0 to where fixed area starts)
173
+ max_row = fixed_area_start_row
174
+ (0...max_row).each do |row|
165
175
  screen.move_cursor(row, 0)
166
176
  screen.clear_line
167
177
  end
168
- # Reset output row position to start
178
+
179
+ # Reset output position to beginning
169
180
  @output_row = 0
181
+
170
182
  # Re-render fixed areas to ensure they stay in place
171
183
  render_fixed_areas
172
184
  screen.flush
@@ -174,89 +186,111 @@ module Clacky
174
186
  end
175
187
 
176
188
  # Append content to output area
177
- # Track current row, scroll when reaching fixed area
178
- # @param content [String] Content to append
189
+ # This is the main output method - handles scrolling and fixed area preservation
190
+ # @param content [String] Content to append (can be multi-line)
179
191
  def append_output(content)
180
192
  return if content.nil?
181
193
 
182
194
  @render_mutex.synchronize do
183
- max_output_row = fixed_area_start_row - 1
184
-
185
- # Special handling for empty string - just add a blank line
186
- if content.empty?
187
- print "\n"
188
- @output_row += 1
189
- render_fixed_areas
190
- screen.flush
191
- return
192
- end
195
+ lines = content.split("\n", -1) # -1 to keep trailing empty strings
193
196
 
194
- content.split("\n").each do |line|
197
+ lines.each_with_index do |line, index|
195
198
  # Wrap long lines to prevent display issues
196
199
  wrapped_lines = wrap_long_line(line)
197
-
198
- wrapped_lines.each do |wrapped_line|
199
- # If at max row, need to scroll before outputting
200
- if @output_row > max_output_row
201
- # Move to bottom of screen and print newline to trigger scroll
202
- screen.move_cursor(screen.height - 1, 0)
203
- print "\n"
204
- # Stay at max_output_row for next output
205
- @output_row = max_output_row
206
- end
207
200
 
208
- # Output line at current position
209
- screen.move_cursor(@output_row, 0)
210
- screen.clear_line
211
- output_area.append(wrapped_line)
212
- @output_row += 1
201
+ wrapped_lines.each do |wrapped_line|
202
+ write_output_line(wrapped_line)
213
203
  end
214
204
  end
215
205
 
216
- # Re-render fixed areas at screen bottom
206
+ # Re-render fixed areas to ensure they stay at bottom
217
207
  render_fixed_areas
218
208
  screen.flush
219
209
  end
220
210
  end
221
211
 
222
- # Update the last line in output area (for progress indicator)
223
- # @param content [String] Content to update
224
- def update_last_line(content)
212
+ # Update the last N lines in output area (for inline input updates)
213
+ # @param content [String] Content to update (may contain newlines for wrapped lines)
214
+ # @param old_line_count [Integer] Number of lines currently occupied (for clearing)
215
+ def update_last_line(content, old_line_count = 1)
225
216
  @render_mutex.synchronize do
226
- # Last output line is at @output_row - 1
227
- last_row = [@output_row - 1, 0].max
228
- screen.move_cursor(last_row, 0)
229
- screen.clear_line
230
- output_area.append(content)
217
+ return if @output_row == 0 # No output yet
218
+
219
+ # Calculate start row (last N lines)
220
+ start_row = @output_row - old_line_count
221
+ start_row = 0 if start_row < 0
222
+
223
+ # Clear all lines that will be updated
224
+ (start_row...@output_row).each do |row|
225
+ screen.move_cursor(row, 0)
226
+ screen.clear_line
227
+ end
228
+
229
+ # Re-render the content
230
+ lines = content.split("\n", -1)
231
+ current_row = start_row
232
+
233
+ lines.each_with_index do |line, idx|
234
+ screen.move_cursor(current_row, 0)
235
+ print line
236
+ current_row += 1
237
+ end
238
+
239
+ # Update output_row to new line count
240
+ @output_row = start_row + lines.length
241
+
242
+ # Clear any remaining old lines if new content has fewer lines
243
+ # This handles the case where content shrinks (e.g., delete from 2 lines to 1 line)
244
+ old_end_row = @output_row + (old_line_count - lines.length)
245
+ if old_end_row > @output_row && old_end_row <= start_row + old_line_count
246
+ # Clear the extra old lines
247
+ (@output_row...old_end_row).each do |row|
248
+ screen.move_cursor(row, 0)
249
+ screen.clear_line
250
+ end
251
+ end
252
+
253
+ # Re-render fixed areas to restore cursor position in input area
231
254
  render_fixed_areas
232
255
  screen.flush
233
256
  end
234
257
  end
235
258
 
236
- # Remove the last line from output area
237
- def remove_last_line
259
+ # Remove the last N lines from output area
260
+ # @param line_count [Integer] Number of lines to remove (default: 1)
261
+ def remove_last_line(line_count = 1)
238
262
  @render_mutex.synchronize do
239
- last_row = [@output_row - 1, 0].max
240
- screen.move_cursor(last_row, 0)
241
- screen.clear_line
242
- @output_row = last_row if @output_row > 0
263
+ return if @output_row == 0 # No output to remove
264
+
265
+ # Calculate start row for removal
266
+ start_row = @output_row - line_count
267
+ start_row = 0 if start_row < 0
268
+
269
+ # Clear all lines being removed
270
+ (start_row...@output_row).each do |row|
271
+ screen.move_cursor(row, 0)
272
+ screen.clear_line
273
+ end
274
+
275
+ # Update output_row
276
+ @output_row = start_row
277
+
278
+ # Re-render fixed areas to ensure consistency
243
279
  render_fixed_areas
244
280
  screen.flush
245
281
  end
246
282
  end
247
283
 
248
- # Scroll output area up
284
+ # Scroll output area up (legacy no-op)
249
285
  # @param lines [Integer] Number of lines to scroll
250
286
  def scroll_output_up(lines = 1)
251
- output_area.scroll_up(lines)
252
- render_output
287
+ # No-op - terminal handles scrolling natively
253
288
  end
254
289
 
255
- # Scroll output area down
290
+ # Scroll output area down (legacy no-op)
256
291
  # @param lines [Integer] Number of lines to scroll
257
292
  def scroll_output_down(lines = 1)
258
- output_area.scroll_down(lines)
259
- render_output
293
+ # No-op - terminal handles scrolling natively
260
294
  end
261
295
 
262
296
  # Handle window resize
@@ -266,12 +300,14 @@ module Clacky
266
300
  screen.update_dimensions
267
301
  calculate_layout
268
302
 
269
- # Adjust output_row if it exceeds new max
270
- max_row = fixed_area_start_row - 1
271
- @output_row = [@output_row, max_row].min
303
+ # Adjust @output_row if it exceeds new layout
304
+ # After resize, @output_row should not exceed fixed_area_start_row
305
+ max_allowed = fixed_area_start_row
306
+ @output_row = [@output_row, max_allowed].min
272
307
 
273
- # Clear old fixed area lines
274
- ([old_gap_row, 0].max...screen.height).each do |row|
308
+ # Clear old fixed area and some lines above (terminal may have wrapped content)
309
+ clear_start = [old_gap_row - 5, 0].max
310
+ (clear_start...screen.height).each do |row|
275
311
  screen.move_cursor(row, 0)
276
312
  screen.clear_line
277
313
  end
@@ -282,6 +318,35 @@ module Clacky
282
318
 
283
319
  private
284
320
 
321
+ # Write a single line to output area
322
+ # Handles scrolling when reaching fixed area
323
+ # @param line [String] Single line to write (should not contain newlines)
324
+ def write_output_line(line)
325
+ # Calculate where fixed area starts (this is where output area ends)
326
+ max_output_row = fixed_area_start_row
327
+
328
+ # If we're about to write into the fixed area, scroll first
329
+ if @output_row >= max_output_row
330
+ # Trigger terminal scroll by printing newline at bottom
331
+ screen.move_cursor(screen.height - 1, 0)
332
+ print "\n"
333
+
334
+ # After scroll, position to write at the last row of output area
335
+ @output_row = max_output_row - 1
336
+
337
+ # Important: Re-render fixed areas after scroll to prevent corruption
338
+ render_fixed_areas
339
+ end
340
+
341
+ # Now write the line at current position
342
+ screen.move_cursor(@output_row, 0)
343
+ screen.clear_line
344
+ print line
345
+
346
+ # Move to next row for next write
347
+ @output_row += 1
348
+ end
349
+
285
350
  # Wrap a long line into multiple lines based on terminal width
286
351
  # Considers display width of multi-byte characters (e.g., Chinese characters)
287
352
  # @param line [String] Line to wrap
@@ -392,11 +457,26 @@ module Clacky
392
457
  # The InlineInput is rendered inline with output
393
458
  return if input_area.paused?
394
459
 
460
+ current_fixed_height = fixed_area_height
395
461
  start_row = fixed_area_start_row
396
462
  gap_row = start_row
397
463
  todo_row = gap_row + 1
398
464
  input_row = todo_row + (@todo_area&.height || 0)
399
465
 
466
+ # If fixed area shrank, clear the extra lines at the top to remove residual content
467
+ if @last_fixed_area_height > current_fixed_height
468
+ height_diff = @last_fixed_area_height - current_fixed_height
469
+ old_start_row = screen.height - @last_fixed_area_height
470
+ # Clear the extra lines that are no longer part of fixed area
471
+ (old_start_row...(old_start_row + height_diff)).each do |row|
472
+ screen.move_cursor(row, 0)
473
+ screen.clear_line
474
+ end
475
+ end
476
+
477
+ # Update last height for next comparison
478
+ @last_fixed_area_height = current_fixed_height
479
+
400
480
  # Render gap line
401
481
  screen.move_cursor(gap_row, 0)
402
482
  screen.clear_line
@@ -412,7 +492,7 @@ module Clacky
412
492
 
413
493
  # Internal render all (without mutex)
414
494
  def render_all_internal
415
- output_area.render(start_row: 0)
495
+ # Output flows naturally, just render fixed areas
416
496
  render_fixed_areas
417
497
  screen.flush
418
498
  end
@@ -188,14 +188,154 @@ module Clacky
188
188
  # Strip ANSI codes from prompt to get actual display width
189
189
  visible_prompt = strip_ansi_codes(prompt)
190
190
  prompt_display_width = calculate_display_width(visible_prompt)
191
-
191
+
192
192
  # Calculate display width of text before cursor
193
193
  chars = @line.chars
194
194
  text_before_cursor = chars[0...@cursor_position].join
195
195
  text_display_width = calculate_display_width(text_before_cursor)
196
-
196
+
197
197
  prompt_display_width + text_display_width
198
198
  end
199
+
200
+ # Get cursor position considering line wrapping
201
+ # @param prompt [String] Prompt string before the line (may contain ANSI codes)
202
+ # @param width [Integer] Terminal width for wrapping
203
+ # @return [Array<Integer>] Row and column position (0-indexed)
204
+ def cursor_position_with_wrap(prompt = "", width = TTY::Screen.width)
205
+ return [0, cursor_column(prompt)] if width <= 0
206
+
207
+ prompt_width = calculate_display_width(strip_ansi_codes(prompt))
208
+ available_width = width - prompt_width
209
+
210
+ # Get wrapped segments for current line
211
+ wrapped_segments = wrap_line(@line, available_width)
212
+
213
+ # Find which segment contains cursor
214
+ cursor_segment_idx = 0
215
+ cursor_pos_in_segment = @cursor_position
216
+
217
+ wrapped_segments.each_with_index do |segment, idx|
218
+ if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
219
+ cursor_segment_idx = idx
220
+ cursor_pos_in_segment = @cursor_position - segment[:start]
221
+ break
222
+ elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
223
+ cursor_segment_idx = idx
224
+ cursor_pos_in_segment = segment[:end] - segment[:start]
225
+ break
226
+ end
227
+ end
228
+
229
+ # Calculate display width of text before cursor in this segment
230
+ chars = @line.chars
231
+ segment_start = wrapped_segments[cursor_segment_idx][:start]
232
+ text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
233
+ display_width = calculate_display_width(text_in_segment_before_cursor)
234
+
235
+ col = prompt_width + display_width
236
+ row = cursor_segment_idx
237
+
238
+ [row, col]
239
+ end
240
+
241
+ # Wrap a line into multiple segments based on available width
242
+ # Considers display width of characters (multi-byte characters like Chinese)
243
+ # @param line [String] The line to wrap
244
+ # @param max_width [Integer] Maximum display width per wrapped line
245
+ # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
246
+ def wrap_line(line, max_width)
247
+ return [{ text: "", start: 0, end: 0 }] if line.empty?
248
+ return [{ text: line, start: 0, end: line.length }] if max_width <= 0
249
+
250
+ segments = []
251
+ chars = line.chars
252
+ segment_start = 0
253
+ current_width = 0
254
+ current_end = 0
255
+
256
+ chars.each_with_index do |char, idx|
257
+ char_width = char_display_width(char)
258
+
259
+ # If adding this character exceeds max width, complete current segment
260
+ if current_width + char_width > max_width && current_end > segment_start
261
+ segments << {
262
+ text: chars[segment_start...current_end].join,
263
+ start: segment_start,
264
+ end: current_end
265
+ }
266
+ segment_start = idx
267
+ current_end = idx + 1
268
+ current_width = char_width
269
+ else
270
+ current_end = idx + 1
271
+ current_width += char_width
272
+ end
273
+ end
274
+
275
+ # Add the last segment
276
+ if current_end > segment_start
277
+ segments << {
278
+ text: chars[segment_start...current_end].join,
279
+ start: segment_start,
280
+ end: current_end
281
+ }
282
+ end
283
+
284
+ segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
285
+ end
286
+
287
+ # Calculate display width of a single character
288
+ # @param char [String] Single character
289
+ # @return [Integer] Display width (1 or 2)
290
+ def char_display_width(char)
291
+ code = char.ord
292
+ # East Asian Wide and Fullwidth characters take 2 columns
293
+ if (code >= 0x1100 && code <= 0x115F) ||
294
+ (code >= 0x2329 && code <= 0x232A) ||
295
+ (code >= 0x2E80 && code <= 0x303E) ||
296
+ (code >= 0x3040 && code <= 0xA4CF) ||
297
+ (code >= 0xAC00 && code <= 0xD7A3) ||
298
+ (code >= 0xF900 && code <= 0xFAFF) ||
299
+ (code >= 0xFE10 && code <= 0xFE19) ||
300
+ (code >= 0xFE30 && code <= 0xFE6F) ||
301
+ (code >= 0xFF00 && code <= 0xFF60) ||
302
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
303
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
304
+ (code >= 0x20000 && code <= 0x2FFFD) ||
305
+ (code >= 0x30000 && code <= 0x3FFFD)
306
+ 2
307
+ else
308
+ 1
309
+ end
310
+ end
311
+
312
+ # Render a segment of a line with cursor if cursor is in this segment
313
+ # @param line [String] Full line text
314
+ # @param segment_start [Integer] Start position of segment in line (char index)
315
+ # @param segment_end [Integer] End position of segment in line (char index)
316
+ # @return [String] Rendered segment with cursor if applicable
317
+ def render_line_segment_with_cursor(line, segment_start, segment_end)
318
+ chars = line.chars
319
+ segment_chars = chars[segment_start...segment_end]
320
+
321
+ # Check if cursor is in this segment
322
+ if @cursor_position >= segment_start && @cursor_position < segment_end
323
+ # Cursor is in this segment
324
+ cursor_pos_in_segment = @cursor_position - segment_start
325
+ before_cursor = segment_chars[0...cursor_pos_in_segment].join
326
+ cursor_char = segment_chars[cursor_pos_in_segment] || " "
327
+ after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
328
+
329
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
330
+ elsif @cursor_position == segment_end && segment_end == line.length
331
+ # Cursor is at the very end of the line, show it in last segment
332
+ segment_text = segment_chars.join
333
+ "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
334
+ else
335
+ # Cursor is not in this segment, just format normally
336
+ @pastel.white(segment_chars.join)
337
+ end
338
+ end
199
339
  end
200
340
  end
201
341
  end
@@ -29,12 +29,12 @@ module Clacky
29
29
  }.freeze
30
30
 
31
31
  COLORS = {
32
- user: [:bright_blue, :blue],
32
+ user: [:white, :white],
33
33
  assistant: [:bright_green, :white],
34
34
  tool_call: [:bright_cyan, :cyan],
35
- tool_result: [:cyan, :white],
35
+ tool_result: [:bright_cyan, :cyan],
36
36
  tool_denied: [:bright_yellow, :yellow],
37
- tool_planned: [:bright_blue, :blue],
37
+ tool_planned: [:bright_cyan, :cyan],
38
38
  tool_error: [:bright_red, :red],
39
39
  thinking: [:dim, :dim],
40
40
  working: [:bright_yellow, :yellow],
@@ -26,12 +26,12 @@ module Clacky
26
26
  }.freeze
27
27
 
28
28
  COLORS = {
29
- user: [:blue, :blue],
29
+ user: [:white, :white],
30
30
  assistant: [:green, :white],
31
31
  tool_call: [:cyan, :cyan],
32
- tool_result: [:white, :white],
32
+ tool_result: [:cyan, :cyan],
33
33
  tool_denied: [:yellow, :yellow],
34
- tool_planned: [:blue, :blue],
34
+ tool_planned: [:cyan, :cyan],
35
35
  tool_error: [:red, :red],
36
36
  thinking: [:dim, :dim],
37
37
  working: [:bright_yellow, :yellow],