kward 0.66.0 → 0.67.0

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.
@@ -4,8 +4,7 @@ require "tty-cursor"
4
4
  require "tty-reader"
5
5
  require "tty-screen"
6
6
  require_relative "ansi"
7
- require_relative "resources/pixel_logo"
8
- require_relative "resources/avatar_kward_logo"
7
+ require_relative "prompt_interface/banner"
9
8
 
10
9
  module Kward
11
10
  class PromptInterface
@@ -16,11 +15,8 @@ module Kward
16
15
  FOOTER_REFRESH_INTERVAL = 1.0
17
16
  COMPOSER_MAX_INPUT_ROWS = 6
18
17
  TRANSCRIPT_BUFFER_LIMIT = 200_000
19
- BANNER_LOGO_WIDTH = 32
20
- BANNER_LOGO_PIXEL_HEIGHT = 32
21
- BANNER_MIN_LOGO_HEIGHT = 4
22
- BANNER_LOGO_PIXELS = Kward::Resources::AvatarKwardLogo::PIXELS
23
- BANNER_MESSAGE = "State your business.".freeze
18
+ BANNER_LOGO_PIXELS = Banner::LOGO_PIXELS
19
+ BANNER_MESSAGE = Banner::MESSAGE
24
20
  KEYBOARD_PROTOCOL_ENABLE = "\e[>1u".freeze
25
21
  KEYBOARD_PROTOCOL_RESTORE = "\e[<u".freeze
26
22
  BRACKETED_PASTE_ENABLE = "\e[?2004h".freeze
@@ -65,10 +61,13 @@ module Kward
65
61
  @assistant_label = "Assistant"
66
62
  @stream_block = nil
67
63
  @rendered_rows = 0
64
+ @last_composer_rows = []
68
65
  @cursor_rendered_row = 0
69
66
  @stream_col = 0
70
67
  @stream_pending_wrap = false
71
68
  @transcript_buffer = +""
69
+ @transcript_display_rows_cache_width = nil
70
+ @transcript_display_rows_cache = nil
72
71
  @visual_banner_count = 0
73
72
  @transcript_viewport_rows = 0
74
73
  @restoring_transcript = false
@@ -80,6 +79,7 @@ module Kward
80
79
  @history = []
81
80
  @history_index = nil
82
81
  @history_draft = nil
82
+ @prefill_input = nil
83
83
  @slash_commands = normalize_slash_commands(slash_commands)
84
84
  @slash_selection_index = 0
85
85
  @slash_overlay_dismissed_input = nil
@@ -90,15 +90,14 @@ module Kward
90
90
  @reserved_rows = 0
91
91
  @color_enabled = ANSI.enabled?(output)
92
92
  @cursor_visible = true
93
+ @synchronized_output_depth = 0
93
94
  @overlay_settings = normalize_overlay_settings(overlay_settings)
94
95
  @footer = footer
95
96
  @composer_status = composer_status
96
97
  @busy_help = busy_help
97
98
  @attachment_badges = attachment_badges
98
99
  @attachment_parser = attachment_parser
99
- @banner_message = banner_message.to_s
100
- @banner_logo_pixels = banner_pixels
101
- @banner_logo_cache = {}
100
+ @banner = Banner.new(message: banner_message, pixels: banner_pixels, screen_height: method(:screen_height))
102
101
  end
103
102
 
104
103
  def start
@@ -140,11 +139,13 @@ module Kward
140
139
  next
141
140
  end
142
141
 
143
- clear_prompt_for_output_locked
144
- write_transcript_text_locked(text)
145
- write_transcript_text_locked("\n") unless text.end_with?("\n")
146
- @stream_block = nil
147
- render_prompt_after_output_locked
142
+ with_synchronized_output_locked do
143
+ clear_prompt_for_output_locked
144
+ write_transcript_text_locked(text)
145
+ write_transcript_text_locked("\n") unless text.end_with?("\n")
146
+ @stream_block = nil
147
+ render_prompt_after_output_locked
148
+ end
148
149
  @output_io.flush
149
150
  end
150
151
  end
@@ -153,12 +154,14 @@ module Kward
153
154
  @mutex.synchronize do
154
155
  return if @restoring_transcript
155
156
 
156
- clear_prompt_for_output_locked
157
- text = message.to_s
158
- write_visual_transcript_text_locked(text)
159
- write_visual_transcript_text_locked("\n") unless text.end_with?("\n")
160
- @stream_block = nil
161
- render_prompt_after_output_locked
157
+ with_synchronized_output_locked do
158
+ clear_prompt_for_output_locked
159
+ text = message.to_s
160
+ write_visual_transcript_text_locked(text)
161
+ write_visual_transcript_text_locked("\n") unless text.end_with?("\n")
162
+ @stream_block = nil
163
+ render_prompt_after_output_locked
164
+ end
162
165
  @output_io.flush
163
166
  end
164
167
  end
@@ -168,6 +171,7 @@ module Kward
168
171
  clear_prompt_for_output_locked
169
172
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
170
173
  @transcript_buffer = +""
174
+ invalidate_transcript_display_rows_cache
171
175
  @visual_banner_count = 0
172
176
  @transcript_viewport_rows = 0
173
177
  @stream_block = nil
@@ -181,7 +185,8 @@ module Kward
181
185
  @mutex.synchronize do
182
186
  @restoring_transcript = false
183
187
  @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
184
- redraw_screen_locked
188
+ width, height = screen_size
189
+ redraw_screen_locked(width: width, height: height)
185
190
  @output_io.flush
186
191
  end
187
192
  end
@@ -193,8 +198,9 @@ module Kward
193
198
  preserve_input = was_composing && !@busy && !@input.empty?
194
199
  @prompt_label = message.to_s
195
200
  unless preserve_input
196
- @input = ""
197
- @cursor = 0
201
+ @input = @prefill_input.to_s
202
+ @prefill_input = nil
203
+ @cursor = @input.length
198
204
  @attachments.clear
199
205
  reset_history_navigation
200
206
  end
@@ -235,7 +241,7 @@ module Kward
235
241
  answer.start_with?("y")
236
242
  end
237
243
 
238
- def select(message, choices, title: "Sessions", custom: false)
244
+ def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
239
245
  return nil if choices.empty? && !custom
240
246
 
241
247
  start
@@ -248,7 +254,9 @@ module Kward
248
254
  @asking = true
249
255
  @busy = false
250
256
  @queued_count = 0
251
- @select_state = { choices: choices.map(&:to_s), selection_index: 0, title: title.to_s, custom: custom }
257
+ choice_labels = choices.map(&:to_s)
258
+ selection_index = choice_labels.empty? ? 0 : [[initial_index.to_i, 0].max, choice_labels.length - 1].min
259
+ @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom }
252
260
  reset_history_navigation
253
261
  render_prompt_locked
254
262
  end
@@ -390,59 +398,54 @@ module Kward
390
398
 
391
399
  def print_visual_banner
392
400
  @mutex.synchronize do
393
- rows = banner_rows(screen_width)
401
+ width, height = screen_size
402
+ rows = banner_rows(width)
394
403
  return if rows.empty?
395
404
 
396
- prepare_transcript_output_locked
397
- rows.each do |row|
398
- write_visual_transcript_text_locked(row)
399
- write_visual_transcript_text_locked("\n")
405
+ with_synchronized_output_locked do
406
+ prepare_transcript_output_locked
407
+ rows.each do |row|
408
+ write_visual_transcript_text_locked(row)
409
+ write_visual_transcript_text_locked("\n")
410
+ end
411
+ @visual_banner_count += 1
412
+ invalidate_transcript_display_rows_cache
413
+ remember_transcript_viewport_locked(height)
414
+ @stream_block = nil
415
+ restore_composer_cursor_locked
400
416
  end
401
- @visual_banner_count += 1
402
- remember_transcript_viewport_locked
403
- @stream_block = nil
404
- restore_composer_cursor_locked
405
417
  @output_io.flush
406
418
  end
407
419
  end
408
420
 
409
421
  def start_stream_block(label)
410
422
  @mutex.synchronize do
411
- if @stream_block != label
412
- prepare_transcript_output_locked unless @restoring_transcript
413
- ensure_transcript_block_separator_locked
414
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n")
415
- @stream_block = label
416
- end
417
- restore_composer_cursor_locked unless @restoring_transcript
418
- @output_io.flush unless @restoring_transcript
423
+ write_stream_block_locked(label, "", finish: false)
419
424
  end
420
425
  end
421
426
 
422
427
  def write_delta(delta)
423
428
  @mutex.synchronize do
424
- prepare_transcript_output_locked unless @restoring_transcript
425
- write_transcript_text_locked(delta.to_s)
426
- restore_composer_cursor_locked unless @restoring_transcript
427
- @output_io.flush unless @restoring_transcript
429
+ write_stream_block_locked(nil, delta.to_s, finish: false)
428
430
  end
429
431
  end
430
432
 
431
433
  def finish_stream_block
432
434
  @mutex.synchronize do
433
- prepare_transcript_output_locked unless @restoring_transcript
434
- if @stream_block
435
- write_transcript_text_locked("\n")
436
- end
437
- @stream_block = nil
438
- restore_composer_cursor_locked unless @restoring_transcript
439
- @output_io.flush unless @restoring_transcript
435
+ write_stream_block_locked(nil, "", finish: true)
436
+ end
437
+ end
438
+
439
+ def write_stream_block(label, delta, finish: false)
440
+ @mutex.synchronize do
441
+ write_stream_block_locked(label, delta.to_s, finish: finish)
440
442
  end
441
443
  end
442
444
 
443
445
  def redraw
444
446
  @mutex.synchronize do
445
- redraw_screen_locked
447
+ width, height = screen_size
448
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
446
449
  @output_io.flush
447
450
  end
448
451
  end
@@ -450,12 +453,14 @@ module Kward
450
453
  def clear_transcript
451
454
  @mutex.synchronize do
452
455
  @transcript_buffer = +""
456
+ invalidate_transcript_display_rows_cache
453
457
  @visual_banner_count = 0
454
458
  @transcript_viewport_rows = 0
455
459
  @stream_block = nil
456
460
  @stream_col = 0
457
461
  @stream_pending_wrap = false
458
- redraw_screen_locked
462
+ width, height = screen_size
463
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
459
464
  @output_io.flush
460
465
  end
461
466
  end
@@ -486,6 +491,39 @@ module Kward
486
491
  @raw_mode_active = false
487
492
  end
488
493
 
494
+ def write_stream_block_locked(label, delta, finish: false)
495
+ with_synchronized_output_locked do
496
+ prepare_transcript_output_locked unless @restoring_transcript
497
+ if label && @stream_block != label
498
+ ensure_transcript_block_separator_locked
499
+ write_transcript_text_locked("#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n")
500
+ @stream_block = label
501
+ end
502
+ write_transcript_text_locked(delta) unless delta.empty?
503
+ write_transcript_text_locked("\n") if finish && @stream_block
504
+ @stream_block = nil if finish
505
+ restore_composer_cursor_locked unless @restoring_transcript
506
+ end
507
+ @output_io.flush unless @restoring_transcript
508
+ end
509
+
510
+ def with_synchronized_output_locked
511
+ if @restoring_transcript || @synchronized_output_depth.positive?
512
+ yield
513
+ return
514
+ end
515
+
516
+ synchronized = true
517
+ @synchronized_output_depth += 1
518
+ @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
519
+ yield
520
+ ensure
521
+ if synchronized
522
+ @synchronized_output_depth -= 1
523
+ @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE) if @synchronized_output_depth.zero?
524
+ end
525
+ end
526
+
489
527
  def write_transcript_text_locked(text)
490
528
  append_transcript_buffer(text.to_s)
491
529
  remember_transcript_viewport_locked unless text.to_s.empty?
@@ -493,19 +531,26 @@ module Kward
493
531
  end
494
532
 
495
533
  def write_visual_transcript_text_locked(text)
534
+ width, height = screen_size
496
535
  output_text = terminal_newlines(text.to_s)
497
- advance_pending_stream_wrap_locked(output_text)
536
+ advance_pending_stream_wrap_locked(output_text, width: width, height: height)
498
537
  @output_io.print(output_text)
499
- update_stream_position(output_text)
538
+ update_stream_position(output_text, width: width)
500
539
  end
501
540
 
502
541
  def append_transcript_buffer(text)
503
542
  @transcript_buffer << ANSI.sanitize_transcript(text)
543
+ invalidate_transcript_display_rows_cache
504
544
  return if @transcript_buffer.length <= TRANSCRIPT_BUFFER_LIMIT
505
545
 
506
546
  @transcript_buffer = @transcript_buffer[-TRANSCRIPT_BUFFER_LIMIT, TRANSCRIPT_BUFFER_LIMIT]
507
547
  end
508
548
 
549
+ def invalidate_transcript_display_rows_cache
550
+ @transcript_display_rows_cache_width = nil
551
+ @transcript_display_rows_cache = nil
552
+ end
553
+
509
554
  def ensure_transcript_block_separator_locked
510
555
  return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
511
556
 
@@ -1618,6 +1663,12 @@ module Kward
1618
1663
  @cursor = @input.length
1619
1664
  end
1620
1665
 
1666
+ def prefill_input(value)
1667
+ @mutex.synchronize do
1668
+ @prefill_input = value.to_s
1669
+ end
1670
+ end
1671
+
1621
1672
  def reset_history_navigation
1622
1673
  @history_index = nil
1623
1674
  @history_draft = nil
@@ -1698,14 +1749,15 @@ module Kward
1698
1749
  return unless @started && @asking
1699
1750
 
1700
1751
  handle_resize_locked
1701
- rows, cursor_row, cursor_col = composer_layout(screen_width)
1702
- ensure_scroll_region_locked(rows.length)
1752
+ width, height = screen_size
1753
+ rows, cursor_row, cursor_col = composer_layout(width, height)
1754
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
1703
1755
  @rendered_rows = rows.length
1704
- render_composer_rows_locked(rows)
1756
+ render_composer_rows_locked(rows, height: height)
1705
1757
  @cursor_rendered_row = cursor_row
1706
- @last_width = screen_width
1707
- @last_height = screen_height
1708
- move_to_screen(composer_top_row + cursor_row, cursor_col + 1)
1758
+ @last_width = width
1759
+ @last_height = height
1760
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
1709
1761
  render_cursor_visibility_locked
1710
1762
  @output_io.flush
1711
1763
  end
@@ -1716,26 +1768,29 @@ module Kward
1716
1768
 
1717
1769
  def clear_prompt_locked
1718
1770
  handle_resize_locked
1719
- clear_composer_region_locked
1771
+ width, height = screen_size
1772
+ clear_composer_region_locked(height: height)
1720
1773
  @rendered_rows = 0
1721
1774
  @cursor_rendered_row = 0
1722
- redraw_transcript_locked
1775
+ redraw_transcript_locked(width: width, height: height)
1723
1776
  end
1724
1777
 
1725
1778
  def clear_prompt_for_output_locked
1726
1779
  handle_resize_locked
1727
- reserve_composer_region_locked if @started && @asking
1728
- clear_composer_region_locked
1780
+ width, height = screen_size
1781
+ reserve_composer_region_locked(width: width, height: height) if @started && @asking
1782
+ clear_composer_region_locked(height: height)
1729
1783
  @rendered_rows = 0
1730
1784
  @cursor_rendered_row = 0
1731
- move_to_transcript_cursor_locked if @started
1785
+ move_to_transcript_cursor_locked(width: width, height: height) if @started
1732
1786
  end
1733
1787
 
1734
1788
  def prepare_transcript_output_locked
1735
1789
  handle_resize_locked
1790
+ width, height = screen_size
1736
1791
  hide_cursor_for_transcript_output_locked
1737
- reserve_composer_region_locked
1738
- move_to_transcript_cursor_locked
1792
+ reserve_composer_region_locked(width: width, height: height)
1793
+ move_to_transcript_cursor_locked(width: width, height: height)
1739
1794
  end
1740
1795
 
1741
1796
  def hide_cursor_for_transcript_output_locked
@@ -1747,8 +1802,9 @@ module Kward
1747
1802
  def restore_composer_cursor_locked
1748
1803
  return unless @started && @asking
1749
1804
 
1750
- _rows, cursor_row, cursor_col = composer_layout(screen_width)
1751
- move_to_screen(composer_top_row + cursor_row, cursor_col + 1)
1805
+ width, height = screen_size
1806
+ _rows, cursor_row, cursor_col = composer_layout(width, height)
1807
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
1752
1808
  render_cursor_visibility_locked
1753
1809
  end
1754
1810
 
@@ -1764,26 +1820,25 @@ module Kward
1764
1820
  @cursor_visible = visible
1765
1821
  end
1766
1822
 
1767
- def reserve_composer_region_locked
1768
- rows, = composer_layout(screen_width)
1769
- ensure_scroll_region_locked(rows.length)
1823
+ def reserve_composer_region_locked(width: screen_width, height: screen_height)
1824
+ rows, = composer_layout(width, height)
1825
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
1770
1826
  end
1771
1827
 
1772
- def ensure_scroll_region_locked(row_count, redraw_transcript: true)
1773
- new_reserved_rows = [[row_count, 1].max, [screen_height - 1, 1].max].min
1774
- return if @reserved_rows == new_reserved_rows && @last_height == screen_height
1828
+ def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
1829
+ new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
1830
+ return if @reserved_rows == new_reserved_rows && @last_height == height
1775
1831
 
1776
1832
  old_reserved_rows = @reserved_rows
1777
1833
  rows_to_clear = [old_reserved_rows, new_reserved_rows].max
1778
1834
  @reserved_rows = new_reserved_rows
1779
- @output_io.print("\e[1;#{transcript_bottom_row}r")
1780
- clear_composer_region_locked(rows_to_clear)
1781
- redraw_transcript_locked if redraw_transcript && new_reserved_rows < old_reserved_rows
1835
+ @output_io.print("\e[1;#{transcript_bottom_row(height)}r")
1836
+ clear_composer_region_locked(rows_to_clear, height: height)
1837
+ redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
1782
1838
  end
1783
1839
 
1784
1840
  def handle_resize_locked
1785
- current_width = screen_width
1786
- current_height = screen_height
1841
+ current_width, current_height = screen_size
1787
1842
  return false if current_width == @last_width && current_height == @last_height
1788
1843
 
1789
1844
  old_width = @last_width
@@ -1795,7 +1850,7 @@ module Kward
1795
1850
  @reserved_rows = 0
1796
1851
  @last_width = current_width
1797
1852
  @last_height = current_height
1798
- redraw_screen_locked
1853
+ redraw_screen_locked(width: current_width, height: current_height)
1799
1854
  true
1800
1855
  end
1801
1856
 
@@ -1804,18 +1859,33 @@ module Kward
1804
1859
  @reserved_rows = 0
1805
1860
  end
1806
1861
 
1807
- def render_composer_rows_locked(rows)
1808
- clear_composer_region_locked
1809
- top = composer_top_row
1810
- rows.each_with_index do |row, index|
1862
+ def render_composer_rows_locked(rows, height: screen_height)
1863
+ top = composer_top_row(height)
1864
+ max_rows = [@last_composer_rows.length, rows.length].max
1865
+ rows_to_clear = [@reserved_rows - rows.length, 0].max
1866
+
1867
+ max_rows.times do |index|
1868
+ row = rows[index]
1869
+ previous = @last_composer_rows[index]
1870
+ next if row == previous
1871
+
1811
1872
  move_to_screen(top + index, 1)
1812
- @output_io.print(row) unless row.empty?
1873
+ @output_io.print(TTY::Cursor.clear_line)
1874
+ @output_io.print(row) unless row.to_s.empty?
1813
1875
  end
1876
+
1877
+ rows.length.upto(rows.length + rows_to_clear - 1) do |index|
1878
+ move_to_screen(top + index, 1)
1879
+ @output_io.print(TTY::Cursor.clear_line)
1880
+ end
1881
+
1882
+ @last_composer_rows = rows.dup
1814
1883
  end
1815
1884
 
1816
- def clear_composer_region_locked(rows_to_clear = nil)
1885
+ def clear_composer_region_locked(rows_to_clear = nil, height: screen_height)
1817
1886
  rows_to_clear ||= [@reserved_rows, @rendered_rows].max
1818
- clear_bottom_rows_locked(screen_height, rows_to_clear)
1887
+ clear_bottom_rows_locked(height, rows_to_clear)
1888
+ @last_composer_rows = []
1819
1889
  end
1820
1890
 
1821
1891
  def resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
@@ -1838,7 +1908,7 @@ module Kward
1838
1908
  def clear_bottom_rows_locked(height, rows_to_clear)
1839
1909
  return unless rows_to_clear.positive?
1840
1910
 
1841
- bottom = [height, screen_height].min
1911
+ bottom = height
1842
1912
  top = [bottom - rows_to_clear + 1, 1].max
1843
1913
  clear_screen_rows_locked(top, bottom)
1844
1914
  end
@@ -1850,32 +1920,33 @@ module Kward
1850
1920
  end
1851
1921
  end
1852
1922
 
1853
- def redraw_screen_locked
1923
+ def redraw_screen_locked(width: screen_width, height: screen_height)
1854
1924
  return unless @started
1855
1925
 
1856
1926
  restore_scroll_region_locked
1857
1927
  @output_io.print(TTY::Cursor.clear_screen)
1858
1928
  move_to_screen(1, 1)
1859
1929
  @reserved_rows = 0
1860
- rows, cursor_row, cursor_col = composer_layout(screen_width)
1861
- ensure_scroll_region_locked(rows.length, redraw_transcript: false)
1862
- redraw_transcript_locked
1930
+ @last_composer_rows = []
1931
+ rows, cursor_row, cursor_col = composer_layout(width, height)
1932
+ ensure_scroll_region_locked(rows.length, redraw_transcript: false, width: width, height: height)
1933
+ redraw_transcript_locked(width: width, height: height)
1863
1934
  @rendered_rows = @asking ? rows.length : 0
1864
- render_composer_rows_locked(rows) if @asking
1935
+ render_composer_rows_locked(rows, height: height) if @asking
1865
1936
  @cursor_rendered_row = @asking ? cursor_row : 0
1866
- @last_width = screen_width
1867
- @last_height = screen_height
1868
- reset_stream_position_from_transcript_locked
1937
+ @last_width = width
1938
+ @last_height = height
1939
+ reset_stream_position_from_transcript_locked(width)
1869
1940
  if @asking
1870
- move_to_screen(composer_top_row + cursor_row, cursor_col + 1)
1941
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
1871
1942
  render_cursor_visibility_locked
1872
1943
  end
1873
1944
  end
1874
1945
 
1875
- def redraw_transcript_locked
1946
+ def redraw_transcript_locked(width: screen_width, height: screen_height)
1876
1947
  return unless transcript_renderable?
1877
1948
 
1878
- rows = transcript_viewport_rows(transcript_redraw_row_count, screen_width)
1949
+ rows = transcript_viewport_rows(transcript_redraw_row_count(height), width)
1879
1950
  clear_screen_rows_locked(1, rows.length)
1880
1951
  return if rows.empty?
1881
1952
 
@@ -1895,12 +1966,12 @@ module Kward
1895
1966
  rows
1896
1967
  end
1897
1968
 
1898
- def transcript_redraw_row_count
1899
- [[@transcript_viewport_rows, transcript_bottom_row].max, screen_height].min
1969
+ def transcript_redraw_row_count(height = screen_height)
1970
+ [[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
1900
1971
  end
1901
1972
 
1902
- def remember_transcript_viewport_locked
1903
- @transcript_viewport_rows = transcript_bottom_row
1973
+ def remember_transcript_viewport_locked(height = screen_height)
1974
+ @transcript_viewport_rows = transcript_bottom_row(height)
1904
1975
  end
1905
1976
 
1906
1977
  def transcript_renderable?
@@ -1908,11 +1979,14 @@ module Kward
1908
1979
  end
1909
1980
 
1910
1981
  def transcript_display_rows(width)
1982
+ return @transcript_display_rows_cache if @transcript_display_rows_cache_width == width && @transcript_display_rows_cache
1983
+
1911
1984
  rows = []
1912
1985
  @visual_banner_count.times { rows.concat(banner_rows(width)) }
1913
1986
  rows << "" if @visual_banner_count.positive? && @transcript_buffer.empty?
1914
1987
  rows.concat(transcript_text_display_rows(width))
1915
- rows
1988
+ @transcript_display_rows_cache_width = width
1989
+ @transcript_display_rows_cache = rows
1916
1990
  end
1917
1991
 
1918
1992
  def transcript_text_display_rows(width)
@@ -1922,8 +1996,7 @@ module Kward
1922
1996
  end
1923
1997
  end
1924
1998
 
1925
- def reset_stream_position_from_transcript_locked
1926
- width = screen_width
1999
+ def reset_stream_position_from_transcript_locked(width = screen_width)
1927
2000
  rows = transcript_display_rows(width)
1928
2001
  last_length = rows.empty? ? 0 : ANSI.strip(rows.last).length
1929
2002
  if last_length >= width
@@ -1935,34 +2008,34 @@ module Kward
1935
2008
  end
1936
2009
  end
1937
2010
 
1938
- def move_to_transcript_cursor_locked
2011
+ def move_to_transcript_cursor_locked(width: screen_width, height: screen_height)
1939
2012
  if @stream_pending_wrap
1940
- move_to_screen(transcript_bottom_row, screen_width)
2013
+ move_to_screen(transcript_bottom_row(height), width)
1941
2014
  else
1942
- move_to_screen(transcript_bottom_row, [@stream_col + 1, screen_width].min)
2015
+ move_to_screen(transcript_bottom_row(height), [@stream_col + 1, width].min)
1943
2016
  end
1944
2017
  end
1945
2018
 
1946
- def advance_pending_stream_wrap_locked(output_text)
2019
+ def advance_pending_stream_wrap_locked(output_text, width: screen_width, height: screen_height)
1947
2020
  return unless @stream_pending_wrap
1948
2021
  return if output_text.empty? || output_text.start_with?("\r", "\n")
1949
2022
 
1950
- move_to_screen(transcript_bottom_row, screen_width)
2023
+ move_to_screen(transcript_bottom_row(height), width)
1951
2024
  @output_io.print("\r\n")
1952
2025
  @stream_col = 0
1953
2026
  @stream_pending_wrap = false
1954
2027
  end
1955
2028
 
1956
- def composer_layout(width)
1957
- return compact_composer_layout(width) if screen_height < 4
1958
- return question_composer_layout(width) if @question_state
2029
+ def composer_layout(width, height = screen_height)
2030
+ return compact_composer_layout(width) if height < 4
2031
+ return question_composer_layout(width, height) if @question_state
1959
2032
 
1960
2033
  content_width = [width - 4, 1].max
1961
2034
  input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
1962
2035
  attachment_rows = attachment_badge_rows(content_width)
1963
- overlay_rows = active_overlay_rows(width)
2036
+ overlay_rows = active_overlay_rows(width, height: height)
1964
2037
  footer_text = footer_text()
1965
- max_input_rows = max_visible_input_rows(attachment_rows.length, overlay_rows.length, footer_text.empty? ? 0 : 1)
2038
+ max_input_rows = max_visible_input_rows(attachment_rows.length, overlay_rows.length, footer_text.empty? ? 0 : 1, height: height)
1966
2039
  visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
1967
2040
  visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
1968
2041
  rows = overlay_rows + [top_border(width)]
@@ -1975,75 +2048,28 @@ module Kward
1975
2048
  [rows, cursor_row, cursor_col]
1976
2049
  end
1977
2050
 
1978
- def question_composer_layout(width)
2051
+ def question_composer_layout(width, height = screen_height)
1979
2052
  content_width = [width - 4, 1].max
1980
- overlay_rows = active_overlay_rows(width)
2053
+ overlay_rows = active_overlay_rows(width, height: height)
1981
2054
  rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
1982
2055
  return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
1983
2056
 
1984
2057
  [rows, overlay_rows.length + 1, 2]
1985
2058
  end
1986
2059
 
1987
- def active_overlay_rows(width)
2060
+ def active_overlay_rows(width, height: screen_height)
1988
2061
  return question_overlay_rows(width) if @question_state
1989
- return selection_overlay_rows(width) if @select_state
2062
+ return selection_overlay_rows(width, height: height) if @select_state
1990
2063
 
1991
- slash_overlay_rows(width)
2064
+ slash_overlay_rows(width, height: height)
1992
2065
  end
1993
2066
 
1994
2067
  def banner_rows(width)
1995
- return [] unless banner_visible?
1996
-
1997
- rows = []
1998
- if banner_image_visible?
1999
- rows.concat(centered_banner_image_rows(width))
2000
- end
2001
- rows << align_plain_row(@banner_message, width) unless @banner_message.empty?
2002
- rows << ""
2003
- rows
2004
- end
2005
-
2006
- def banner_visible?
2007
- !@banner_message.empty? || banner_image_visible?
2008
- end
2009
-
2010
- def banner_image_visible?
2011
- !banner_logo_rows.empty?
2012
- end
2013
-
2014
- def centered_banner_image_rows(width)
2015
- logo_width, = banner_logo_dimensions(width)
2016
- padding = [[(width - logo_width) / 2, 0].max, width - 1].min
2017
- banner_logo_rows.map { |row| (" " * padding) + row }
2068
+ @banner.rows(width)
2018
2069
  end
2019
2070
 
2020
2071
  def banner_logo_rows
2021
- logo_width, logo_height = banner_logo_dimensions(screen_width)
2022
- return [] unless @banner_logo_pixels && max_banner_logo_height >= BANNER_MIN_LOGO_HEIGHT
2023
-
2024
- key = [logo_width, logo_height]
2025
- @banner_logo_cache[key] ||= Kward::PixelLogo.half_block_rows_from_pixels(@banner_logo_pixels, width: logo_width, pixel_height: logo_height)
2026
- end
2027
-
2028
- def banner_logo_dimensions(width)
2029
- logo_width = [BANNER_LOGO_WIDTH, [width - 2, 1].max].min
2030
- logo_height = [BANNER_LOGO_PIXEL_HEIGHT, max_banner_logo_height * 2].min
2031
- [logo_width, logo_height]
2032
- end
2033
-
2034
- def max_banner_logo_height
2035
- message_rows = @banner_message.empty? ? 0 : 1
2036
- blank_after_banner = 1
2037
- minimum_composer_rows = 3
2038
- transcript_row = 1
2039
- reserved_rows = message_rows + blank_after_banner + minimum_composer_rows + transcript_row
2040
- [screen_height - reserved_rows, 0].max
2041
- end
2042
-
2043
- def align_plain_row(text, width)
2044
- plain_length = ANSI.strip(text).length
2045
- padding = [width - plain_length, 0].max / 2
2046
- (" " * padding) + text.to_s
2072
+ @banner.logo_rows(screen_width)
2047
2073
  end
2048
2074
 
2049
2075
  def question_overlay_rows(width)
@@ -2060,10 +2086,10 @@ module Kward
2060
2086
  overlay_card_rows(title, lines, width)
2061
2087
  end
2062
2088
 
2063
- def slash_overlay_rows(width)
2089
+ def slash_overlay_rows(width, height: screen_height)
2064
2090
  return [] unless slash_overlay_visible?
2065
2091
 
2066
- visible = visible_slash_overlay_matches(slash_overlay_matches)
2092
+ visible = visible_slash_overlay_matches(slash_overlay_matches, height: height)
2067
2093
  start_index = visible[:start]
2068
2094
  lines = visible[:commands].each_with_index.map do |command, offset|
2069
2095
  index = start_index + offset
@@ -2074,13 +2100,13 @@ module Kward
2074
2100
  overlay_card_rows("Slash commands", lines, width)
2075
2101
  end
2076
2102
 
2077
- def visible_slash_overlay_matches(matches)
2078
- max_rows = [[screen_height - 7, 1].max, 8].min
2103
+ def visible_slash_overlay_matches(matches, height: screen_height)
2104
+ max_rows = [[height - 7, 1].max, 8].min
2079
2105
  start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
2080
2106
  { start: start, commands: matches[start, max_rows] || [] }
2081
2107
  end
2082
2108
 
2083
- def selection_overlay_rows(width)
2109
+ def selection_overlay_rows(width, height: screen_height)
2084
2110
  matches = selection_matches
2085
2111
  lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
2086
2112
  if matches.empty?
@@ -2092,7 +2118,7 @@ module Kward
2092
2118
  return overlay_card_rows(selection_overlay_title, lines, width)
2093
2119
  end
2094
2120
 
2095
- visible = visible_selection_matches(matches)
2121
+ visible = visible_selection_matches(matches, height: height)
2096
2122
  start_index = visible[:start]
2097
2123
  visible[:choices].each_with_index do |choice, offset|
2098
2124
  index = start_index + offset
@@ -2106,8 +2132,8 @@ module Kward
2106
2132
  title && !title.empty? ? title : "Sessions"
2107
2133
  end
2108
2134
 
2109
- def visible_selection_matches(matches)
2110
- max_rows = [[screen_height - 7, 1].max, 8].min
2135
+ def visible_selection_matches(matches, height: screen_height)
2136
+ max_rows = [[height - 7, 1].max, 8].min
2111
2137
  start = [[selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
2112
2138
  { start: start, choices: matches[start, max_rows] || [] }
2113
2139
  end
@@ -2354,17 +2380,17 @@ module Kward
2354
2380
  []
2355
2381
  end
2356
2382
 
2357
- def max_visible_input_rows(attachment_count = 0, overlay_count = active_overlay_rows(screen_width).length, footer_count = footer_text.to_s.empty? ? 0 : 1)
2383
+ def max_visible_input_rows(attachment_count = 0, overlay_count = active_overlay_rows(screen_width).length, footer_count = footer_text.to_s.empty? ? 0 : 1, height: screen_height)
2358
2384
  input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
2359
- [[input_cap, screen_height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
2385
+ [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
2360
2386
  end
2361
2387
 
2362
- def composer_top_row
2363
- [screen_height - @reserved_rows + 1, 1].max
2388
+ def composer_top_row(height = screen_height)
2389
+ [height - @reserved_rows + 1, 1].max
2364
2390
  end
2365
2391
 
2366
- def transcript_bottom_row
2367
- [screen_height - @reserved_rows, 1].max
2392
+ def transcript_bottom_row(height = screen_height)
2393
+ [height - @reserved_rows, 1].max
2368
2394
  end
2369
2395
 
2370
2396
  def move_to_screen(row, col)
@@ -2385,8 +2411,7 @@ module Kward
2385
2411
  [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
2386
2412
  end
2387
2413
 
2388
- def update_stream_position(text)
2389
- width = screen_width
2414
+ def update_stream_position(text, width: screen_width)
2390
2415
  ANSI.strip(text).each_char do |char|
2391
2416
  case char
2392
2417
  when "\n", "\r"
@@ -2426,6 +2451,10 @@ module Kward
2426
2451
  end
2427
2452
  end
2428
2453
 
2454
+ def screen_size
2455
+ [screen_width, screen_height]
2456
+ end
2457
+
2429
2458
  def screen_width
2430
2459
  [TTY::Screen.width, 1].max
2431
2460
  end