Class: Clacky::UI2::LayoutManager

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/ui2/layout_manager.rb

Overview

LayoutManager manages screen layout with split areas (output area on top, input area on bottom)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(input_area:, todo_area: nil) ⇒ LayoutManager

Returns a new instance of LayoutManager.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/clacky/ui2/layout_manager.rb', line 13

def initialize(input_area:, todo_area: nil)
  @screen = ScreenBuffer.new
  @input_area = input_area
  @todo_area = todo_area
  @render_mutex = Mutex.new
  @output_row = 0  # Track current output row position
  @last_fixed_area_height = 0  # Track previous fixed area height to detect shrinkage
  @fullscreen_mode = false  # Track if in fullscreen mode
  @resize_pending = false  # Flag to indicate resize is pending
  @output_buffer = Utils::LimitStack.new(max_size: 500)  # Buffer to store output lines with auto-rolling

  calculate_layout
  setup_resize_handler
end

Instance Attribute Details

#input_areaObject (readonly)

Returns the value of attribute input_area.



11
12
13
# File 'lib/clacky/ui2/layout_manager.rb', line 11

def input_area
  @input_area
end

#screenObject (readonly)

Returns the value of attribute screen.



11
12
13
# File 'lib/clacky/ui2/layout_manager.rb', line 11

def screen
  @screen
end

#todo_areaObject (readonly)

Returns the value of attribute todo_area.



11
12
13
# File 'lib/clacky/ui2/layout_manager.rb', line 11

def todo_area
  @todo_area
end

Instance Method Details

#append_output(content) ⇒ Object

Append content to output area This is the main output method - handles scrolling and fixed area preservation

Parameters:

  • content (String)

    Content to append (can be multi-line)



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/clacky/ui2/layout_manager.rb', line 241

def append_output(content)
  return if content.nil?

  # Scrub any invalid byte sequences before they reach the render pipeline.
  # wrap_long_line calls each_char which raises ArgumentError on invalid UTF-8.
  content = Clacky::Utils::Encoding.sanitize_utf8(content) unless content.valid_encoding?

  @render_mutex.synchronize do
    lines = content.split("\n", -1)  # -1 to keep trailing empty strings

    lines.each_with_index do |line, index|
      # Wrap long lines to prevent display issues
      wrapped_lines = wrap_long_line(line)

      wrapped_lines.each do |wrapped_line|
        write_output_line(wrapped_line)
      end
    end

    # Re-render fixed areas to ensure they stay at bottom
    render_fixed_areas
    screen.flush
  end
end

#calculate_display_width(text) ⇒ Integer

Calculate display width of a string (considering multi-byte characters)

Parameters:

  • text (String)

    Text to calculate

Returns:

  • (Integer)

    Display width



559
560
561
562
563
564
565
# File 'lib/clacky/ui2/layout_manager.rb', line 559

def calculate_display_width(text)
  width = 0
  text.each_char do |char|
    width += char_display_width(char)
  end
  width
end

#calculate_layoutObject

Calculate layout dimensions based on screen size and component heights



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/clacky/ui2/layout_manager.rb', line 29

def calculate_layout
  todo_height = @todo_area&.height || 0
  input_height = @input_area.required_height
  gap_height = 1  # Blank line between output and input

  # Layout: output -> gap -> todo -> input (with its own separators and status)
  @output_height = screen.height - gap_height - todo_height - input_height
  @output_height = [1, @output_height].max  # Minimum 1 line for output

  @gap_row = @output_height
  @todo_row = @gap_row + gap_height
  @input_row = @todo_row + todo_height

  # Update component dimensions
  @input_area.row = @input_row
end

#char_display_width(char) ⇒ Integer

Calculate display width of a single character

Parameters:

  • char (String)

    Single character

Returns:

  • (Integer)

    Display width (1 or 2)



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'lib/clacky/ui2/layout_manager.rb', line 534

def char_display_width(char)
  code = char.ord
  # East Asian Wide and Fullwidth characters take 2 columns
  if (code >= 0x1100 && code <= 0x115F) ||
     (code >= 0x2329 && code <= 0x232A) ||
     (code >= 0x2E80 && code <= 0x303E) ||
     (code >= 0x3040 && code <= 0xA4CF) ||
     (code >= 0xAC00 && code <= 0xD7A3) ||
     (code >= 0xF900 && code <= 0xFAFF) ||
     (code >= 0xFE10 && code <= 0xFE19) ||
     (code >= 0xFE30 && code <= 0xFE6F) ||
     (code >= 0xFF00 && code <= 0xFF60) ||
     (code >= 0xFFE0 && code <= 0xFFE6) ||
     (code >= 0x1F300 && code <= 0x1F9FF) ||
     (code >= 0x20000 && code <= 0x2FFFD) ||
     (code >= 0x30000 && code <= 0x3FFFD)
    2
  else
    1
  end
end

#cleanup_screenObject

Cleanup the screen (restore cursor)



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/clacky/ui2/layout_manager.rb', line 198

def cleanup_screen
  @render_mutex.synchronize do
    # Clear fixed areas (gap + todo + input)
    fixed_start = fixed_area_start_row
    (fixed_start...screen.height).each do |row|
      screen.move_cursor(row, 0)
      screen.clear_line
    end

    # Move cursor to start of a new line after last output
    # Use \r to ensure we're at column 0, then move down
    screen.move_cursor([@output_row, 0].max, 0)
    print "\r"  # Carriage return to column 0
    screen.show_cursor
    screen.flush
  end
end

#clear_outputObject

Clear output area (for /clear command)



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/clacky/ui2/layout_manager.rb', line 217

def clear_output
  @render_mutex.synchronize do
    # Clear all lines in output area (from 0 to where fixed area starts)
    max_row = fixed_area_start_row
    (0...max_row).each do |row|
      screen.move_cursor(row, 0)
      screen.clear_line
    end

    # Reset output position to beginning
    @output_row = 0

    # Clear the output buffer so re-renders don't restore old content
    @output_buffer.clear

    # Re-render fixed areas to ensure they stay in place
    render_fixed_areas
    screen.flush
  end
end

#enter_fullscreen(lines, hint: "Press Ctrl+O to return") ⇒ Object

Enter fullscreen mode with alternate screen buffer

Parameters:

  • lines (Array<String>)

    Lines to display

  • hint (String) (defaults to: "Press Ctrl+O to return")

    Hint message at bottom



657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'lib/clacky/ui2/layout_manager.rb', line 657

def enter_fullscreen(lines, hint: "Press Ctrl+O to return")
  @render_mutex.synchronize do
    return if @fullscreen_mode

    @fullscreen_mode = true
    @fullscreen_hint = hint

    # Enter alternate screen buffer and do a full clean:
    #   \e[?1049h  - switch to alternate screen buffer (separate from primary)
    #   \e[2J      - erase the entire visible screen
    #   \e[H       - move cursor to top-left
    # The alternate screen buffer has no scrollback history by design, so
    # there is nothing to scroll up to once we clear the visible area.
    print "\e[?1049h\e[2J\e[H"
    $stdout.flush

    render_fullscreen_content(lines)
  end
end

#exit_fullscreenObject

Exit fullscreen mode and restore previous screen



690
691
692
693
694
695
696
697
698
699
700
701
# File 'lib/clacky/ui2/layout_manager.rb', line 690

def exit_fullscreen
  @render_mutex.synchronize do
    return unless @fullscreen_mode

    @fullscreen_mode = false
    @fullscreen_hint = nil

    # Exit alternate screen buffer (automatically restores previous screen content)
    print "\e[?1049l"
    $stdout.flush
  end
end

#fixed_area_heightObject

Calculate fixed area height (gap + todo + input)



568
569
570
571
572
# File 'lib/clacky/ui2/layout_manager.rb', line 568

def fixed_area_height
  todo_height = @todo_area&.height || 0
  input_height = @input_area.required_height
  1 + todo_height + input_height  # gap + todo + input
end

#fixed_area_start_rowObject

Calculate the starting row for fixed areas (from screen bottom)



575
576
577
# File 'lib/clacky/ui2/layout_manager.rb', line 575

def fixed_area_start_row
  screen.height - fixed_area_height
end

#fullscreen_mode?Boolean

Check if in fullscreen mode

Returns:

  • (Boolean)


650
651
652
# File 'lib/clacky/ui2/layout_manager.rb', line 650

def fullscreen_mode?
  @fullscreen_mode
end

#initialize_screenObject

Initialize the screen (render initial content)



190
191
192
193
194
195
# File 'lib/clacky/ui2/layout_manager.rb', line 190

def initialize_screen
  screen.clear_screen
  screen.hide_cursor
  @output_row = 0
  render_all
end

#position_inline_input_cursor(inline_input) ⇒ Object

Position cursor for inline input in output area

Parameters:



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/clacky/ui2/layout_manager.rb', line 140

def position_inline_input_cursor(inline_input)
  return unless inline_input

  # Use InlineInput's method to calculate cursor position (handles continuation prompt correctly)
  width = screen.width
  wrap_row, wrap_col = inline_input.cursor_position_for_display(width)

  # Get the number of lines InlineInput occupies (considering wrapping)
  line_count = inline_input.line_count(width)

  # InlineInput starts at @output_row - line_count
  # Cursor is at wrap_row within that
  cursor_row = @output_row - line_count + wrap_row
  cursor_col = wrap_col

  # Move terminal cursor to the correct position
  screen.move_cursor(cursor_row, cursor_col)
  screen.flush
end

#process_pending_resizeObject

Check and process pending resize (should be called from main thread periodically)



754
755
756
757
758
759
# File 'lib/clacky/ui2/layout_manager.rb', line 754

def process_pending_resize
  return unless @resize_pending

  @resize_pending = false
  handle_resize_safely
end

#recalculate_layoutObject

Recalculate layout (called when input height changes)



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/clacky/ui2/layout_manager.rb', line 47

def recalculate_layout
  @render_mutex.synchronize do
    # Save old layout values before recalculating
    old_gap_row = @gap_row  # This is the old fixed_area_start
    old_input_row = @input_row

    calculate_layout

    # If layout changed, clear old fixed area and re-render at new position
    if @input_row != old_input_row
      # Clear old fixed area lines (from old gap_row to screen bottom)
      ([old_gap_row, 0].max...screen.height).each do |row|
        screen.move_cursor(row, 0)
        screen.clear_line
      end

      # When input is paused (InlineInput active), fixed_area_start_row has grown
      # (input_area.required_height returns 0 while paused), so the cleared rows
      # now belong to the output area. Re-render output from buffer to fill them in.
      if input_area.paused?
        render_output_from_buffer
      else
        # Re-render fixed areas at new position
        render_fixed_areas
      end
      screen.flush
    end
  end
end

#refresh_fullscreen(lines) ⇒ Object

Refresh fullscreen content in-place (for real-time updates without re-entering alt screen)

Parameters:

  • lines (Array<String>)

    Updated lines to display



679
680
681
682
683
684
685
686
687
# File 'lib/clacky/ui2/layout_manager.rb', line 679

def refresh_fullscreen(lines)
  @render_mutex.synchronize do
    return unless @fullscreen_mode

    # Move cursor to top-left and erase visible area, then redraw
    print "\e[2J\e[H"
    render_fullscreen_content(lines)
  end
end

#remove_last_line(line_count = 1) ⇒ Object

Remove the last N lines from output area

Parameters:

  • line_count (Integer) (defaults to: 1)

    Number of lines to remove (default: 1)



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/clacky/ui2/layout_manager.rb', line 355

def remove_last_line(line_count = 1)
  @render_mutex.synchronize do
    # Fullscreen owns the alternate screen; skip main-screen updates
    return if @fullscreen_mode

    return if @output_row == 0  # No output to remove

    # Calculate start row for removal
    start_row = @output_row - line_count
    start_row = 0 if start_row < 0

    # Clear all lines being removed
    (start_row...@output_row).each do |row|
      screen.move_cursor(row, 0)
      screen.clear_line
    end

    # Also remove from output buffer to prevent re-rendering
    line_count.times do
      @output_buffer.pop if @output_buffer.size > 0
    end

    # Update output_row
    @output_row = start_row

    # Re-render fixed areas to ensure consistency.
    # Skip buffer re-render: lines were removed both from screen and buffer above,
    # re-rendering would push stale content back into the terminal scrollback.
    render_fixed_areas(skip_buffer_rerender: true)
    screen.flush
  end
end

#render_allObject

Render all layout areas



78
79
80
81
82
# File 'lib/clacky/ui2/layout_manager.rb', line 78

def render_all
  @render_mutex.synchronize do
    render_all_internal
  end
end

#render_all_internalObject

Internal render all (without mutex)



626
627
628
629
630
# File 'lib/clacky/ui2/layout_manager.rb', line 626

def render_all_internal
  # Output flows naturally, just render fixed areas
  render_fixed_areas
  screen.flush
end

#render_fixed_areas(skip_buffer_rerender: false) ⇒ Object

Render fixed areas (gap, todo, input) at screen bottom

Parameters:

  • skip_buffer_rerender (Boolean) (defaults to: false)

    When true, skip the render_output_from_buffer even if fixed_area_height changed. Use this when the caller has already written the correct content directly (update_last_line, remove_last_line) to avoid re-rendering buffer content that would duplicate entries in the terminal scrollback.



584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/clacky/ui2/layout_manager.rb', line 584

def render_fixed_areas(skip_buffer_rerender: false)
  # When input is paused (InlineInput active), don't render fixed areas
  # The InlineInput is rendered inline with output
  return if input_area.paused?

  # Do not corrupt the alternate screen while in fullscreen mode
  return if @fullscreen_mode

  current_fixed_height = fixed_area_height
  start_row = fixed_area_start_row
  gap_row = start_row
  todo_row = gap_row + 1
  input_row = todo_row + (@todo_area&.height || 0)

  # Detect height changes and re-render output area if needed.
  # Skip when the caller (update_last_line / remove_last_line) has already
  # written the correct content directly — re-rendering would push duplicate
  # content into the terminal scrollback, causing repeated lines on scroll-up.
  if !skip_buffer_rerender && @last_fixed_area_height > 0 && @last_fixed_area_height != current_fixed_height
    # Fixed area height changed - re-render output area from buffer
    # This prevents output content from being hidden when fixed area grows
    # (e.g., multi-line input, command suggestions appearing)
    render_output_from_buffer
  end

  # Update last height for next comparison
  @last_fixed_area_height = current_fixed_height

  # Render gap line
  screen.move_cursor(gap_row, 0)
  screen.clear_line

  # Render todo
  if @todo_area&.visible?
    @todo_area.render(start_row: todo_row)
  end

  # Render input (InputArea renders its own visual cursor via render_line_with_cursor)
  input_area.render(start_row: input_row, width: screen.width)
end

#render_inputObject

Render just the input area



94
95
96
97
98
99
100
# File 'lib/clacky/ui2/layout_manager.rb', line 94

def render_input
  @render_mutex.synchronize do
    # Clear and re-render entire fixed area to ensure consistency
    render_fixed_areas
    screen.flush
  end
end

#render_outputObject

Render output area - with native scroll, just ensure input stays in place



85
86
87
88
89
90
91
# File 'lib/clacky/ui2/layout_manager.rb', line 85

def render_output
  @render_mutex.synchronize do
    # Output is written directly, just need to re-render fixed areas
    render_fixed_areas
    screen.flush
  end
end

#rerender_allObject

Re-render everything from scratch (useful after modal dialogs)



103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/clacky/ui2/layout_manager.rb', line 103

def rerender_all
  @render_mutex.synchronize do
    # Clear entire screen
    screen.clear_screen

    # Re-render output from buffer
    render_output_from_buffer

    # Re-render fixed areas at new positions
    render_fixed_areas
    screen.flush
  end
end

#restore_cursor_to_inputObject

Restore cursor to input area



633
634
635
636
637
# File 'lib/clacky/ui2/layout_manager.rb', line 633

def restore_cursor_to_input
  input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
  input_area.position_cursor(input_row)
  screen.show_cursor
end

#restore_screenObject

Restore screen from fullscreen mode (re-render everything)



640
641
642
643
644
645
646
# File 'lib/clacky/ui2/layout_manager.rb', line 640

def restore_screen
  @render_mutex.synchronize do
    screen.clear_screen
    screen.hide_cursor
    render_all_internal
  end
end

#scroll_output_down(lines = 1) ⇒ Object

Scroll output area down (legacy no-op)

Parameters:

  • lines (Integer) (defaults to: 1)

    Number of lines to scroll



396
397
398
# File 'lib/clacky/ui2/layout_manager.rb', line 396

def scroll_output_down(lines = 1)
  # No-op - terminal handles scrolling natively
end

#scroll_output_up(lines = 1) ⇒ Object

Scroll output area up (legacy no-op)

Parameters:

  • lines (Integer) (defaults to: 1)

    Number of lines to scroll



390
391
392
# File 'lib/clacky/ui2/layout_manager.rb', line 390

def scroll_output_up(lines = 1)
  # No-op - terminal handles scrolling natively
end

#update_last_line(content, old_line_count = 1) ⇒ Object

Update the last N lines in output area (for inline input updates)

Parameters:

  • content (String)

    Content to update (may contain newlines for wrapped lines)

  • old_line_count (Integer) (defaults to: 1)

    Number of lines currently occupied (for clearing)



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/clacky/ui2/layout_manager.rb', line 269

def update_last_line(content, old_line_count = 1)
  @render_mutex.synchronize do
    # Fullscreen owns the alternate screen; skip main-screen updates
    return if @fullscreen_mode

    return if @output_row == 0  # No output yet

    lines = content.split("\n", -1)
    new_line_count = lines.length

    # Calculate start row (last N lines)
    start_row = @output_row - old_line_count
    start_row = 0 if start_row < 0

    # If lines grew, check if we would overflow into the fixed area and scroll if needed
    if new_line_count > old_line_count
      max_output_row = fixed_area_start_row
      needed_end_row = start_row + new_line_count

      if needed_end_row > max_output_row
        # Calculate how many extra rows we need
        overflow = needed_end_row - max_output_row

        # Scroll the terminal by printing newlines at the bottom of the output area
        overflow.times do
          screen.move_cursor(screen.height - 1, 0)
          print "\n"
        end

        # Adjust start_row and output_row upward after scroll
        start_row -= overflow
        start_row = 0 if start_row < 0
        @output_row = [start_row + old_line_count, max_output_row].min

        # Re-render fixed areas after scroll to prevent corruption.
        # Skip buffer re-render to avoid duplicating content in scrollback.
        render_fixed_areas(skip_buffer_rerender: true)
      end
    end

    # Clear all lines that will be updated
    (start_row...@output_row).each do |row|
      screen.move_cursor(row, 0)
      screen.clear_line
    end

    # Remove old lines from buffer
    old_line_count.times do
      @output_buffer.pop if @output_buffer.size > 0
    end

    # Re-render the content
    current_row = start_row

    lines.each do |line|
      screen.move_cursor(current_row, 0)
      print line
      # Add updated line to buffer
      @output_buffer << line
      current_row += 1
    end

    # Update output_row to new line count
    @output_row = start_row + new_line_count

    # Clear any remaining old lines if new content has fewer lines
    # This handles the case where content shrinks (e.g., delete from 2 lines to 1 line)
    old_end_row = @output_row + (old_line_count - new_line_count)
    if old_end_row > @output_row && old_end_row <= start_row + old_line_count
      # Clear the extra old lines
      (@output_row...old_end_row).each do |row|
        screen.move_cursor(row, 0)
        screen.clear_line
      end
    end

    # Re-render fixed areas to restore cursor position in input area.
    # Skip buffer re-render: the content was written directly above, so
    # re-rendering from buffer would duplicate it in the terminal scrollback.
    render_fixed_areas(skip_buffer_rerender: true)
    screen.flush
  end
end

#update_todos(todos) ⇒ Object

Update todos and re-render

Parameters:

  • todos (Array<Hash>)

    Array of todo items



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/clacky/ui2/layout_manager.rb', line 162

def update_todos(todos)
  return unless @todo_area

  @render_mutex.synchronize do
    old_height = @todo_area.height
    old_gap_row = @gap_row

    @todo_area.update(todos)
    new_height = @todo_area.height

    # Recalculate layout if height changed
    if old_height != new_height
      calculate_layout

      # Clear old fixed area lines (from old gap_row to screen bottom)
      ([old_gap_row, 0].max...screen.height).each do |row|
        screen.move_cursor(row, 0)
        screen.clear_line
      end
    end

    # Render fixed areas at new position
    render_fixed_areas
    screen.flush
  end
end

#wrap_long_line(line) ⇒ Array<String>

Wrap a long line into multiple lines based on terminal width Considers display width of multi-byte characters (e.g., Chinese characters)

Parameters:

  • line (String)

    Line to wrap

Returns:

  • (Array<String>)

    Array of wrapped lines



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
# File 'lib/clacky/ui2/layout_manager.rb', line 479

def wrap_long_line(line)
  return [""] if line.nil? || line.empty?

  max_width = screen.width
  return [line] if max_width <= 0

  # Strip ANSI codes for width calculation
  visible_line = line.gsub(/\e\[[0-9;]*m/, '')

  # Check if line needs wrapping
  display_width = calculate_display_width(visible_line)
  return [line] if display_width <= max_width

  # Line needs wrapping - split by considering display width
  wrapped = []
  current_line = ""
  current_width = 0
  ansi_codes = []  # Track ANSI codes to carry over

  # Extract ANSI codes and text segments
  segments = line.split(/(\e\[[0-9;]*m)/)

  segments.each do |segment|
    if segment =~ /^\e\[[0-9;]*m$/
      # ANSI code - add to current codes
      ansi_codes << segment
      current_line += segment
    else
      # Text segment - process character by character
      segment.each_char do |char|
        char_width = char_display_width(char)

        if current_width + char_width > max_width && !current_line.empty?
          # Complete current line
          wrapped << current_line
          # Start new line with carried-over ANSI codes
          current_line = ansi_codes.join
          current_width = 0
        end

        current_line += char
        current_width += char_width
      end
    end
  end

  # Add remaining content
  wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join

  wrapped.empty? ? [""] : wrapped
end

#write_output_line(line) ⇒ Object

Write a single line to output area Handles scrolling when reaching fixed area

Parameters:

  • line (String)

    Single line to write (should not contain newlines)



440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/clacky/ui2/layout_manager.rb', line 440

def write_output_line(line)
  # Add to buffer so content is available when returning from fullscreen
  @output_buffer << line

  # Fullscreen owns the alternate screen; skip rendering to avoid corruption
  return if @fullscreen_mode

  # Calculate where fixed area starts (this is where output area ends)
  max_output_row = fixed_area_start_row

  # If we're about to write into the fixed area, scroll first
  if @output_row >= max_output_row
    # Trigger terminal scroll by printing newline at bottom
    screen.move_cursor(screen.height - 1, 0)
    print "\n"

    # After scroll, position to write at the last row of output area
    @output_row = max_output_row - 1

    # Re-render fixed areas after scroll to prevent corruption.
    # Skip buffer re-render: new line hasn't been drawn yet; re-rendering from
    # buffer would put a duplicate into the scrollback (the \n above already
    # pushed previous content up, so a full buffer repaint here would repeat it).
    render_fixed_areas(skip_buffer_rerender: true)
  end

  # Now write the line at current position
  screen.move_cursor(@output_row, 0)
  screen.clear_line
  print line

  # Move to next row for next write
  @output_row += 1
end