kward 0.67.1 → 0.68.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -5,8 +5,34 @@ require "tty-reader"
5
5
  require "tty-screen"
6
6
  require_relative "ansi"
7
7
  require_relative "prompt_interface/banner"
8
-
8
+ require_relative "prompt_interface/composer_state"
9
+ require_relative "prompt_interface/transcript_buffer"
10
+ require_relative "prompt_interface/transcript_renderer"
11
+ require_relative "prompt_interface/prompt_renderer"
12
+ require_relative "prompt_interface/stream_state"
13
+ require_relative "prompt_interface/slash_overlay"
14
+ require_relative "prompt_interface/selection_prompt"
15
+ require_relative "prompt_interface/question_prompt"
16
+ require_relative "prompt_interface/overlay_renderer"
17
+ require_relative "prompt_interface/composer_renderer"
18
+ require_relative "prompt_interface/composer_controller"
19
+ require_relative "prompt_interface/layout"
20
+ require_relative "prompt_interface/screen"
21
+ require_relative "prompt_interface/key_handler"
22
+ require_relative "prompt_interface/runtime_state"
23
+
24
+ # Namespace for the Kward CLI agent runtime.
9
25
  module Kward
26
+ # Interactive terminal UI used by the CLI frontend.
27
+ #
28
+ # `PromptInterface` owns terminal mechanics: keyboard input, composer state,
29
+ # transcript rendering, overlays, footer updates, and terminal escape sequence
30
+ # setup/restore. It should not own agent turns, sessions, model calls, or tool
31
+ # policy; `CLI` coordinates those and calls this object for display/input.
32
+ #
33
+ # The implementation is split into small mixin modules under
34
+ # `prompt_interface/` to keep rendering, layout, keyboard handling, and runtime
35
+ # state readable while sharing one terminal state object.
10
36
  class PromptInterface
11
37
  HELP_TEXT = "Enter sends • Shift+Enter inserts newline • ↑/↓ history • Ctrl+D exits empty prompt".freeze
12
38
  BUSY_HELP_TEXT = "Ctrl+C cancels".freeze
@@ -17,6 +43,19 @@ module Kward
17
43
  TRANSCRIPT_BUFFER_LIMIT = 200_000
18
44
  BANNER_LOGO_PIXELS = Banner::LOGO_PIXELS
19
45
  BANNER_MESSAGE = Banner::MESSAGE
46
+
47
+ include SlashOverlay
48
+ include SelectionPrompt
49
+ include QuestionPrompt
50
+ include OverlayRenderer
51
+ include ComposerRenderer
52
+ include ComposerController
53
+ include Layout
54
+ include Screen
55
+ include KeyHandler
56
+ include RuntimeState
57
+ include TranscriptRenderer
58
+ include PromptRenderer
20
59
  KEYBOARD_PROTOCOL_ENABLE = "\e[>1u".freeze
21
60
  KEYBOARD_PROTOCOL_RESTORE = "\e[<u".freeze
22
61
  BRACKETED_PASTE_ENABLE = "\e[?2004h".freeze
@@ -32,6 +71,7 @@ module Kward
32
71
  CANCEL_INPUT = :cancel_input
33
72
  SELECT_CANCEL = :select_cancel
34
73
 
74
+ # Submitted input string carrying optional display text for transcripts.
35
75
  class SubmittedInput < String
36
76
  attr_reader :display_input
37
77
 
@@ -46,8 +86,9 @@ module Kward
46
86
  @output_io = output
47
87
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
48
88
  @mutex = Mutex.new
49
- @input = ""
50
- @cursor = 0
89
+ @composer = ComposerState.new
90
+ self.composer_input = @composer.input
91
+ self.composer_cursor = @composer.cursor
51
92
  @started = false
52
93
  @asking = false
53
94
  @busy = false
@@ -59,27 +100,17 @@ module Kward
59
100
  @last_footer_refresh = monotonic_now
60
101
  @prompt_label = "You>"
61
102
  @assistant_label = "Assistant"
62
- @stream_block = nil
103
+ @stream_state = StreamState.new
63
104
  @rendered_rows = 0
64
105
  @last_composer_rows = []
65
106
  @cursor_rendered_row = 0
66
- @stream_col = 0
67
- @stream_pending_wrap = false
68
- @transcript_buffer = +""
69
- @transcript_display_rows_cache_width = nil
70
- @transcript_display_rows_cache = nil
107
+ @transcript_buffer = TranscriptBuffer.new(limit: TRANSCRIPT_BUFFER_LIMIT)
71
108
  @visual_banner_count = 0
72
109
  @transcript_viewport_rows = 0
73
110
  @restoring_transcript = false
74
111
  @pending_keys = []
75
- @attachments = []
76
- @kill_buffer = ""
77
112
  @original_console_mode = nil
78
113
  @raw_mode_active = false
79
- @history = []
80
- @history_index = nil
81
- @history_draft = nil
82
- @prefill_input = nil
83
114
  @slash_commands = normalize_slash_commands(slash_commands)
84
115
  @slash_selection_index = 0
85
116
  @slash_overlay_dismissed_input = nil
@@ -135,7 +166,7 @@ module Kward
135
166
  if @restoring_transcript
136
167
  write_transcript_text_locked(text)
137
168
  write_transcript_text_locked("\n") unless text.end_with?("\n")
138
- @stream_block = nil
169
+ @stream_state.finish_block
139
170
  next
140
171
  end
141
172
 
@@ -143,7 +174,7 @@ module Kward
143
174
  clear_prompt_for_output_locked
144
175
  write_transcript_text_locked(text)
145
176
  write_transcript_text_locked("\n") unless text.end_with?("\n")
146
- @stream_block = nil
177
+ @stream_state.finish_block
147
178
  render_prompt_after_output_locked
148
179
  end
149
180
  @output_io.flush
@@ -159,7 +190,7 @@ module Kward
159
190
  text = message.to_s
160
191
  write_visual_transcript_text_locked(text)
161
192
  write_visual_transcript_text_locked("\n") unless text.end_with?("\n")
162
- @stream_block = nil
193
+ @stream_state.finish_block
163
194
  render_prompt_after_output_locked
164
195
  end
165
196
  @output_io.flush
@@ -170,13 +201,11 @@ module Kward
170
201
  @mutex.synchronize do
171
202
  clear_prompt_for_output_locked
172
203
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
173
- @transcript_buffer = +""
174
- invalidate_transcript_display_rows_cache
204
+ @transcript_buffer.clear
175
205
  @visual_banner_count = 0
176
206
  @transcript_viewport_rows = 0
177
- @stream_block = nil
178
- @stream_col = 0
179
- @stream_pending_wrap = false
207
+ @stream_state.finish_block
208
+ @stream_state.reset
180
209
  @restoring_transcript = true
181
210
  end
182
211
 
@@ -195,13 +224,13 @@ module Kward
195
224
  was_composing = @started && @asking
196
225
  start
197
226
  @mutex.synchronize do
198
- preserve_input = was_composing && !@busy && !@input.empty?
227
+ preserve_input = was_composing && !@busy && !composer_input.empty?
199
228
  @prompt_label = message.to_s
200
229
  unless preserve_input
201
- @input = @prefill_input.to_s
202
- @prefill_input = nil
203
- @cursor = @input.length
204
- @attachments.clear
230
+ self.composer_input = @composer.prefill_input.to_s
231
+ @composer.prefill_input = nil
232
+ self.composer_cursor = composer_input.length
233
+ @composer.clear_attachments
205
234
  reset_history_navigation
206
235
  end
207
236
  @pending_keys.clear
@@ -247,10 +276,10 @@ module Kward
247
276
  start
248
277
  @mutex.synchronize do
249
278
  @prompt_label = message.to_s
250
- @input = ""
251
- @cursor = 0
252
- @attachments.clear
253
- @pending_keys.clear
279
+ self.composer_input = ""
280
+ self.composer_cursor = 0
281
+ @composer.clear_attachments
282
+ @pending_keys.clear
254
283
  @asking = true
255
284
  @busy = false
256
285
  @queued_count = 0
@@ -324,10 +353,10 @@ module Kward
324
353
  @mutex.synchronize do
325
354
  @prompt_label = message.to_s
326
355
  @busy_activity = normalize_busy_activity(activity)
327
- @input = ""
328
- @cursor = 0
329
- @attachments.clear
330
- @pending_keys.clear
356
+ self.composer_input = ""
357
+ self.composer_cursor = 0
358
+ @composer.clear_attachments
359
+ @pending_keys.clear
331
360
  @asking = true
332
361
  @busy = true
333
362
  @queued_count = 0
@@ -411,7 +440,7 @@ module Kward
411
440
  @visual_banner_count += 1
412
441
  invalidate_transcript_display_rows_cache
413
442
  remember_transcript_viewport_locked(height)
414
- @stream_block = nil
443
+ @stream_state.finish_block
415
444
  restore_composer_cursor_locked
416
445
  end
417
446
  @output_io.flush
@@ -452,13 +481,11 @@ module Kward
452
481
 
453
482
  def clear_transcript
454
483
  @mutex.synchronize do
455
- @transcript_buffer = +""
456
- invalidate_transcript_display_rows_cache
484
+ @transcript_buffer.clear
457
485
  @visual_banner_count = 0
458
486
  @transcript_viewport_rows = 0
459
- @stream_block = nil
460
- @stream_col = 0
461
- @stream_pending_wrap = false
487
+ @stream_state.finish_block
488
+ @stream_state.reset
462
489
  width, height = screen_size
463
490
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
464
491
  @output_io.flush
@@ -467,2000 +494,210 @@ module Kward
467
494
 
468
495
  private
469
496
 
470
- def enter_raw_mode_locked
471
- return unless @input_io.respond_to?(:tty?) && @input_io.tty?
472
- return unless @input_io.respond_to?(:console_mode) && @input_io.respond_to?(:console_mode=)
473
- return if @raw_mode_active
474
-
475
- @original_console_mode = @input_io.console_mode
476
- raw_mode = @input_io.console_mode.raw
477
- raw_mode.echo = false
478
- @input_io.console_mode = raw_mode
479
- @raw_mode_active = true
480
- rescue StandardError
481
- @original_console_mode = nil
482
- @raw_mode_active = false
483
- end
484
-
485
- def restore_console_mode_locked
486
- return unless @raw_mode_active
487
-
488
- @input_io.console_mode = @original_console_mode if @original_console_mode
489
- ensure
490
- @original_console_mode = nil
491
- @raw_mode_active = false
492
- end
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
-
527
- def write_transcript_text_locked(text)
528
- append_transcript_buffer(text.to_s)
529
- remember_transcript_viewport_locked unless text.to_s.empty?
530
- write_visual_transcript_text_locked(text)
531
- end
532
-
533
- def write_visual_transcript_text_locked(text)
534
- width, height = screen_size
535
- output_text = terminal_newlines(text.to_s)
536
- advance_pending_stream_wrap_locked(output_text, width: width, height: height)
537
- @output_io.print(output_text)
538
- update_stream_position(output_text, width: width)
539
- end
540
-
541
- def append_transcript_buffer(text)
542
- @transcript_buffer << ANSI.sanitize_transcript(text)
543
- invalidate_transcript_display_rows_cache
544
- return if @transcript_buffer.length <= TRANSCRIPT_BUFFER_LIMIT
545
-
546
- @transcript_buffer = @transcript_buffer[-TRANSCRIPT_BUFFER_LIMIT, TRANSCRIPT_BUFFER_LIMIT]
547
- end
548
-
549
- def invalidate_transcript_display_rows_cache
550
- @transcript_display_rows_cache_width = nil
551
- @transcript_display_rows_cache = nil
552
- end
553
-
554
- def ensure_transcript_block_separator_locked
555
- return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
556
-
557
- write_transcript_text_locked(@transcript_buffer.end_with?("\n") ? "\n" : "\n\n")
558
- end
559
-
560
- def terminal_newlines(text)
561
- text.gsub(/\r\n|\r|\n/, "\r\n")
562
- end
563
-
564
- def reset_spinner_locked
565
- @spinner_frame_index = 0
566
- @last_spinner_tick = monotonic_now
567
- end
568
-
569
- def normalize_busy_activity(activity)
570
- text = activity.to_s.gsub(/\s+/, " ").strip
571
- text.empty? ? "streaming" : text
572
- end
573
-
574
- def tick_spinner_locked
575
- return false unless @busy && @queued_count.zero? && @started && @asking
576
-
577
- now = monotonic_now
578
- elapsed = now - @last_spinner_tick
579
- return false if elapsed < SPINNER_INTERVAL
580
-
581
- steps = (elapsed / SPINNER_INTERVAL).floor
582
- @spinner_frame_index = (@spinner_frame_index + steps) % SPINNER_FRAMES.length
583
- @last_spinner_tick += steps * SPINNER_INTERVAL
584
- true
585
- end
586
-
587
- def spinner_frame
588
- SPINNER_FRAMES[@spinner_frame_index % SPINNER_FRAMES.length]
589
- end
590
-
591
- def tick_footer_locked
592
- return false unless @footer && @started && @asking
593
-
594
- now = monotonic_now
595
- elapsed = now - @last_footer_refresh
596
- return false if elapsed < FOOTER_REFRESH_INTERVAL
597
-
598
- steps = (elapsed / FOOTER_REFRESH_INTERVAL).floor
599
- @last_footer_refresh += steps * FOOTER_REFRESH_INTERVAL
600
- true
601
- end
602
-
603
- def monotonic_now
604
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
605
- end
606
-
607
- def submit_input
608
- value = submitted_input
609
- add_history(@input)
610
- if @busy
611
- clear_prompt_for_output_locked
612
- @input = ""
613
- @cursor = 0
614
- @attachments.clear
615
- reset_history_navigation
616
- @asking = true
617
- render_prompt_after_output_locked
618
- else
619
- clear_prompt_locked
620
- @input = ""
621
- @cursor = 0
622
- @attachments.clear
623
- @asking = false
624
- @rendered_rows = 0
625
- @cursor_rendered_row = 0
626
- end
627
- @output_io.flush
628
- value
629
- end
630
-
631
- def submitted_input
632
- return @input if @attachments.empty?
633
-
634
- sources = @attachments.map { |attachment| attachment[:source_text].to_s }.reject(&:empty?)
635
- display_input = @input.to_s.rstrip
636
- full_input = [display_input, *sources].reject { |part| part.to_s.strip.empty? }.join("\n")
637
- SubmittedInput.new(full_input, display_input: display_input)
638
- end
639
-
640
- def exit_input
641
- if @busy
642
- clear_prompt_for_output_locked
643
- @input = ""
644
- @cursor = 0
645
- @attachments.clear
646
- @asking = true
647
- render_prompt_after_output_locked
648
- else
649
- clear_prompt_locked
650
- @input = ""
651
- @cursor = 0
652
- @attachments.clear
653
- @asking = false
654
- @rendered_rows = 0
655
- @cursor_rendered_row = 0
656
- end
657
- @output_io.flush
658
- EXIT_INPUT
659
- end
660
-
661
- def read_key(nonblock: false)
662
- pending = @pending_keys.shift unless @pending_keys.empty?
663
- return pending if pending
664
-
665
- @reader.read_keypress(echo: false, raw: true, nonblock: nonblock)
666
- rescue TTY::Reader::InputInterrupt
667
- "\x03"
668
- rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
669
- nil
670
- end
671
-
672
- def handle_key(key)
673
- return submit_input if key.nil?
674
- return if handle_bracketed_paste_key(key)
675
-
676
- csi_result = handle_csi_u_key(key)
677
- return csi_result unless csi_result == false
678
- return if handle_shift_enter_key(key)
679
- if key.is_a?(String) && key.length > 1
680
- token = next_key_token(key)
681
- if token.length < key.length
682
- queue_pending_keys(key[token.length..])
683
- return handle_key(token)
684
- end
685
- end
686
-
687
- binding_result = handle_composer_key_binding(key)
688
- return binding_result unless binding_result == false
689
-
690
- key_name = @reader.console.keys[key]
691
- case key_name
692
- when :return, :enter
693
- submit_input
694
- when :backspace
695
- delete_before_cursor
696
- when :delete
697
- delete_at_cursor
698
- when :ctrl_d
699
- delete_at_cursor_or_exit
700
- when :ctrl_c
701
- cancel_input_or_interrupt
702
- when :ctrl_a
703
- move_to_start_of_line
704
- when :ctrl_e
705
- move_to_end_of_line
706
- when :ctrl_b
707
- move_cursor_left
708
- when :ctrl_f
709
- move_cursor_right
710
- when :ctrl_w
711
- delete_word_before_cursor
712
- when :ctrl_u
713
- kill_line_before_cursor
714
- when :ctrl_k
715
- kill_line_after_cursor
716
- when :ctrl_y
717
- yank_kill_buffer
718
- when :ctrl_l
719
- redraw_screen_locked
720
- when :left
721
- move_cursor_left
722
- when :right
723
- move_cursor_right
724
- when :home
725
- move_to_start_of_line
726
- when :end
727
- move_to_end_of_line
728
- when :up
729
- slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
730
- when :down
731
- slash_overlay_visible? ? select_next_slash_command : recall_next_history
732
- else
733
- case key
734
- when "\n", "\r"
735
- submit_input
736
- when "\t"
737
- complete_selected_slash_command || insert_key(key)
738
- when "\b", "\x7F"
739
- delete_before_cursor
740
- when "\x04"
741
- delete_at_cursor_or_exit
742
- when "\x03"
743
- cancel_input_or_interrupt
744
- when "\e"
745
- handle_escape_sequence
746
- else
747
- insert_key(key)
748
- end
749
- end
750
- end
751
-
752
- def cancel_input_or_interrupt
753
- return CANCEL_INPUT if @busy
754
-
755
- raise Interrupt
756
- end
757
-
758
- def handle_escape_sequence
759
- pending_sequence = read_pending_escape_sequence
760
- return true if pending_sequence.empty? && dismiss_slash_overlay
761
-
762
- full_sequence = "\e#{pending_sequence}"
763
- sequence = next_key_token(full_sequence)
764
- queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
765
- return true if sequence == "\e" && dismiss_slash_overlay
766
- return true if handle_shift_enter_key(sequence)
767
-
768
- binding_result = handle_composer_key_binding(sequence)
769
- return binding_result unless binding_result == false
770
-
771
- key_name = @reader.console.keys[sequence]
772
- case key_name
773
- when :up
774
- slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
775
- when :down
776
- slash_overlay_visible? ? select_next_slash_command : recall_next_history
777
- when :left
778
- move_cursor_left
779
- when :right
780
- move_cursor_right
781
- when :home
782
- move_to_start_of_line
783
- when :end
784
- move_to_end_of_line
785
- when :delete
786
- delete_at_cursor
787
- end
788
- true
789
- end
790
-
791
- def ask_single_user_question(question, index, total)
792
- @mutex.synchronize do
793
- @prompt_label = "Answer>"
794
- @input = ""
795
- @cursor = 0
796
- @pending_keys.clear
797
- @asking = true
798
- @busy = false
799
- @queued_count = 0
800
- @question_state = {
801
- question: question[:question] || question["question"],
802
- header: question[:header] || question["header"],
803
- options: question[:options] || question["options"],
804
- selection_index: 0,
805
- index: index,
806
- total: total
807
- }
808
- reset_history_navigation
809
- render_prompt_locked
810
- end
811
-
812
- loop do
813
- key = read_key(nonblock: true)
814
- result = nil
815
- @mutex.synchronize do
816
- if key.nil?
817
- resized = handle_resize_locked
818
- footer_refreshed = tick_footer_locked
819
- render_prompt_locked if resized || footer_refreshed
820
- else
821
- result = handle_question_key(key)
822
- render_prompt_locked unless result.is_a?(Hash) || result == SELECT_CANCEL
823
- end
824
- end
825
-
826
- return result if result.is_a?(Hash) || result == SELECT_CANCEL
827
-
828
- sleep 0.02 if key.nil?
829
- end
830
- end
831
-
832
- def begin_question_prompt_state
833
- {
834
- prompt_label: @prompt_label,
835
- input: @input,
836
- cursor: @cursor,
837
- asking: @asking,
838
- busy: @busy,
839
- queued_count: @queued_count,
840
- steered_count: @steered_count,
841
- pending_keys: @pending_keys.dup,
842
- select_state: @select_state
843
- }
844
- end
845
-
846
- def finish_question_prompt(saved_state)
847
- @mutex.synchronize do
848
- @question_state = nil
849
- @select_state = saved_state[:select_state]
850
- @prompt_label = saved_state[:prompt_label]
851
- @input = saved_state[:input]
852
- @cursor = saved_state[:cursor]
853
- @asking = saved_state[:asking]
854
- @busy = saved_state[:busy]
855
- @queued_count = saved_state[:queued_count]
856
- @steered_count = saved_state[:steered_count]
857
- @pending_keys = saved_state[:pending_keys]
858
- render_prompt_locked if @started && @asking
859
- @output_io.flush
860
- end
861
- end
862
-
863
- def handle_question_key(key)
864
- return if handle_question_bracketed_paste_key(key)
865
-
866
- csi_result = handle_question_csi_u_key(key)
867
- return csi_result unless csi_result == false
868
-
869
- if key.is_a?(String) && key.length > 1
870
- token = next_key_token(key)
871
- if token.length < key.length
872
- queue_pending_keys(key[token.length..])
873
- return handle_question_key(token)
874
- end
875
- end
876
-
877
- key_name = @reader.console.keys[key]
878
- case key_name
879
- when :return, :enter
880
- current_question_answer
881
- when :backspace
882
- question_delete_before_cursor
883
- when :delete
884
- question_delete_at_cursor
885
- when :left
886
- @cursor -= 1 if @cursor.positive?
887
- when :right
888
- @cursor += 1 if @cursor < @input.length
889
- when :home
890
- @cursor = 0
891
- when :end
892
- @cursor = @input.length
893
- when :up
894
- question_previous_choice
895
- when :down
896
- question_next_choice
897
- else
898
- case key
899
- when "\n", "\r"
900
- current_question_answer
901
- when "\b", "\x7F"
902
- question_delete_before_cursor
903
- when "\e"
904
- handle_question_escape_sequence
905
- else
906
- question_insert_key(key)
907
- end
908
- end
909
- end
910
-
911
- def handle_question_csi_u_key(key)
912
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
913
- return false unless match
914
-
915
- sequence = match[0]
916
- code = match[1].to_i
917
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
918
-
919
- case code
920
- when 13
921
- current_question_answer
922
- when 27
923
- SELECT_CANCEL
924
- when 8, 127
925
- question_delete_before_cursor
926
- nil
927
- else
928
- false
929
- end
930
- end
931
-
932
- def handle_question_escape_sequence
933
- sequence = read_pending_escape_sequence
934
- return SELECT_CANCEL if sequence.empty?
935
-
936
- key_name = @reader.console.keys["\e#{sequence}"]
937
- case key_name
938
- when :up
939
- question_previous_choice
940
- when :down
941
- question_next_choice
942
- when :left
943
- @cursor -= 1 if @cursor.positive?
944
- when :right
945
- @cursor += 1 if @cursor < @input.length
946
- end
947
- true
948
- end
949
-
950
- def handle_question_bracketed_paste_key(key)
951
- text = key.to_s
952
- return false unless text.start_with?(BRACKETED_PASTE_START)
953
-
954
- pasted = text[BRACKETED_PASTE_START.length..] || ""
955
- until pasted.include?(BRACKETED_PASTE_END)
956
- chunk = @reader.read_keypress(echo: false, raw: true)
957
- break if chunk.nil?
958
-
959
- pasted << chunk.to_s
960
- end
961
-
962
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
963
- question_insert_string(normalize_paste(content || ""))
964
- queue_pending_keys(remaining) if remaining && !remaining.empty?
965
- true
966
- end
967
-
968
- def handle_select_key(key)
969
- return select_current_choice if key.nil?
970
- return if handle_select_bracketed_paste_key(key)
971
-
972
- csi_result = handle_select_csi_u_key(key)
973
- return csi_result unless csi_result == false
974
-
975
- if key.is_a?(String) && key.length > 1
976
- token = next_key_token(key)
977
- if token.length < key.length
978
- queue_pending_keys(key[token.length..])
979
- return handle_select_key(token)
980
- end
981
- end
982
-
983
- key_name = @reader.console.keys[key]
984
- case key_name
985
- when :return, :enter
986
- select_current_choice
987
- when :backspace
988
- select_delete_before_cursor
989
- when :delete
990
- select_delete_at_cursor
991
- when :left
992
- @cursor -= 1 if @cursor.positive?
993
- when :right
994
- @cursor += 1 if @cursor < @input.length
995
- when :home
996
- @cursor = 0
997
- when :end
998
- @cursor = @input.length
999
- when :up
1000
- select_previous_choice
1001
- when :down
1002
- select_next_choice
1003
- else
1004
- case key
1005
- when "\n", "\r"
1006
- select_current_choice
1007
- when "\b", "\x7F"
1008
- select_delete_before_cursor
1009
- when "\e"
1010
- handle_select_escape_sequence
1011
- else
1012
- select_insert_key(key)
1013
- end
1014
- end
1015
- end
1016
-
1017
- def handle_select_csi_u_key(key)
1018
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
1019
- return false unless match
1020
-
1021
- sequence = match[0]
1022
- code = match[1].to_i
1023
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
1024
-
1025
- case code
1026
- when 13
1027
- select_current_choice
1028
- when 27
1029
- SELECT_CANCEL
1030
- when 8, 127
1031
- select_delete_before_cursor
1032
- nil
1033
- else
1034
- false
1035
- end
1036
- end
1037
-
1038
- def handle_select_escape_sequence
1039
- sequence = read_pending_escape_sequence
1040
- return SELECT_CANCEL if sequence.empty?
1041
-
1042
- key_name = @reader.console.keys["\e#{sequence}"]
1043
- case key_name
1044
- when :up
1045
- select_previous_choice
1046
- when :down
1047
- select_next_choice
1048
- when :left
1049
- @cursor -= 1 if @cursor.positive?
1050
- when :right
1051
- @cursor += 1 if @cursor < @input.length
1052
- end
1053
- true
1054
- end
1055
-
1056
- def handle_select_bracketed_paste_key(key)
1057
- text = key.to_s
1058
- return false unless text.start_with?(BRACKETED_PASTE_START)
1059
-
1060
- pasted = text[BRACKETED_PASTE_START.length..] || ""
1061
- until pasted.include?(BRACKETED_PASTE_END)
1062
- chunk = @reader.read_keypress(echo: false, raw: true)
1063
- break if chunk.nil?
1064
-
1065
- pasted << chunk.to_s
1066
- end
1067
-
1068
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
1069
- select_insert_string(normalize_paste(content || ""))
1070
- queue_pending_keys(remaining) if remaining && !remaining.empty?
1071
- true
1072
- end
1073
-
1074
- def handle_bracketed_paste_key(key)
1075
- text = key.to_s
1076
- return false unless text.start_with?(BRACKETED_PASTE_START)
1077
-
1078
- pasted = text[BRACKETED_PASTE_START.length..] || ""
1079
- until pasted.include?(BRACKETED_PASTE_END)
1080
- chunk = @reader.read_keypress(echo: false, raw: true)
1081
- break if chunk.nil?
1082
-
1083
- pasted << chunk.to_s
1084
- end
1085
-
1086
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
1087
- insert_paste(normalize_paste(content || ""))
1088
- queue_pending_keys(remaining) if remaining && !remaining.empty?
1089
- true
1090
- end
1091
-
1092
- def normalize_paste(content)
1093
- content.gsub("\r\n", "\n").gsub("\r", "\n")
1094
- end
1095
-
1096
- def handle_csi_u_key(key)
1097
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
1098
- return false unless match
1099
-
1100
- sequence = match[0]
1101
- code = match[1].to_i
1102
- modifier = (match[2] || "1").split(":", 2).first.to_i
1103
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
1104
-
1105
- case code
1106
- when 13
1107
- modifier == 2 ? insert_string("\n") : submit_input
1108
- when 27
1109
- dismiss_slash_overlay || false
1110
- when 8, 127
1111
- alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
1112
- nil
1113
- when 4
1114
- delete_at_cursor_or_exit
1115
- else
1116
- handle_modified_csi_u_key(code, modifier)
1117
- end
1118
- end
1119
-
1120
- def handle_modified_csi_u_key(code, modifier)
1121
- return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
1122
-
1123
- normalized_code = code.to_i.chr.downcase.ord rescue code
1124
- if ctrl_modifier?(modifier)
1125
- case normalized_code
1126
- when 97
1127
- move_to_start_of_line
1128
- when 98
1129
- move_cursor_left
1130
- when 99
1131
- cancel_input_or_interrupt
1132
- when 100
1133
- delete_at_cursor_or_exit
1134
- when 101
1135
- move_to_end_of_line
1136
- when 102
1137
- move_cursor_right
1138
- when 104
1139
- delete_before_cursor
1140
- when 107
1141
- kill_line_after_cursor
1142
- when 108
1143
- redraw_screen_locked
1144
- when 117
1145
- kill_line_before_cursor
1146
- when 119
1147
- delete_word_before_cursor
1148
- when 121
1149
- yank_kill_buffer
1150
- else
1151
- false
1152
- end
1153
- elsif alt_modifier?(modifier)
1154
- case normalized_code
1155
- when 98
1156
- move_to_previous_word
1157
- when 100
1158
- delete_word_after_cursor
1159
- when 102
1160
- move_to_next_word
1161
- else
1162
- false
1163
- end
1164
- else
1165
- false
1166
- end
1167
- end
1168
-
1169
- def ctrl_modifier?(modifier)
1170
- ((modifier.to_i - 1) & 4).positive?
1171
- end
1172
-
1173
- def alt_modifier?(modifier)
1174
- ((modifier.to_i - 1) & 2).positive?
1175
- end
1176
-
1177
- def handle_shift_enter_key(key)
1178
- sequence = shift_enter_sequence_for(key)
1179
- return false unless sequence
1180
-
1181
- insert_string("\n")
1182
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
1183
- true
1184
- end
1185
-
1186
- def queue_pending_keys(keys)
1187
- remaining = keys.to_s
1188
- until remaining.empty?
1189
- token = next_key_token(remaining)
1190
- @pending_keys << token
1191
- remaining = remaining[token.length..] || ""
1192
- end
1193
- end
1194
-
1195
- def next_key_token(keys)
1196
- text = keys.to_s
1197
- text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
1198
- text.match(/\A\eO[A-Za-z]/)&.[](0) ||
1199
- shift_enter_sequence_for(text) ||
1200
- (text.start_with?("\e") && text.length > 1 && alt_key_sequence?(text[1]) ? text[0, 2] : text[0, 1])
1201
- end
1202
-
1203
- def alt_key_sequence?(char)
1204
- char = char.to_s
1205
- char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
1206
- end
1207
-
1208
- def shift_enter_sequence_for(key)
1209
- return nil unless key.is_a?(String)
1210
-
1211
- SHIFT_ENTER_SEQUENCES.find { |sequence| key.start_with?(sequence) }
1212
- end
1213
-
1214
- def read_pending_escape_sequence
1215
- sequence = +""
1216
- until @pending_keys.empty?
1217
- sequence << @pending_keys.shift.to_s
1218
- end
1219
- while (char = @reader.read_keypress(echo: false, raw: true, nonblock: true))
1220
- sequence << char.to_s
1221
- end
1222
- sequence
1223
- rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
1224
- sequence
1225
- end
1226
-
1227
- def current_question_answer
1228
- choice = selected_question_choice
1229
- return nil unless choice
1230
-
1231
- if choice[:custom]
1232
- answer = @input.strip
1233
- return nil if answer.empty?
1234
-
1235
- { question: current_question_text, answer: answer, custom: true }
1236
- else
1237
- { question: current_question_text, answer: choice[:label], custom: false }
1238
- end
1239
- end
1240
-
1241
- def selected_question_choice
1242
- choices = question_choices
1243
- return nil if choices.empty?
1244
-
1245
- choices[question_selection_index]
1246
- end
1247
-
1248
- def question_choices
1249
- options = Array(@question_state ? @question_state[:options] : []).map do |option|
1250
- { label: (option[:label] || option["label"]).to_s, description: (option[:description] || option["description"]).to_s }
1251
- end
1252
- choices = options + [{ label: "Type something.", description: @input.strip, custom: true }]
1253
- clamp_question_selection_index(choices.length)
1254
- choices
1255
- end
1256
-
1257
- def current_question_text
1258
- (@question_state && @question_state[:question]).to_s
1259
- end
1260
-
1261
- def question_selection_index
1262
- @question_state ? @question_state[:selection_index].to_i : 0
1263
- end
1264
497
 
1265
- def clamp_question_selection_index(count)
1266
- return unless @question_state
1267
498
 
1268
- @question_state[:selection_index] = 0 if count <= 0
1269
- @question_state[:selection_index] = count - 1 if count.positive? && question_selection_index >= count
1270
- end
1271
499
 
1272
- def question_previous_choice
1273
- choices = question_choices
1274
- return if choices.empty?
1275
500
 
1276
- @question_state[:selection_index] = (question_selection_index - 1) % choices.length
1277
- end
1278
501
 
1279
- def question_next_choice
1280
- choices = question_choices
1281
- return if choices.empty?
1282
502
 
1283
- @question_state[:selection_index] = (question_selection_index + 1) % choices.length
1284
- end
1285
503
 
1286
- def question_insert_key(key)
1287
- return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
1288
504
 
1289
- question_insert_string(key)
1290
- end
1291
505
 
1292
- def question_insert_string(string)
1293
- return if string.empty?
1294
506
 
1295
- @input = @input[0...@cursor] + string + @input[@cursor..]
1296
- @cursor += string.length
1297
- @question_state[:selection_index] = question_choices.length - 1 if @question_state
1298
- end
1299
507
 
1300
- def question_delete_before_cursor
1301
- return unless @cursor.positive?
1302
508
 
1303
- @input = @input[0...(@cursor - 1)] + @input[@cursor..]
1304
- @cursor -= 1
1305
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !@input.empty?
1306
- end
1307
509
 
1308
- def question_delete_at_cursor
1309
- return unless @cursor < @input.length
1310
510
 
1311
- @input = @input[0...@cursor] + @input[(@cursor + 1)..]
1312
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !@input.empty?
1313
- end
1314
511
 
1315
- def select_current_choice
1316
- selected_selection_choice || custom_selection_choice || SELECT_CANCEL
1317
- end
1318
512
 
1319
- def custom_selection_choice
1320
- return nil unless @select_state && @select_state[:custom]
1321
513
 
1322
- value = @input.strip
1323
- value.empty? ? nil : value
1324
- end
1325
514
 
1326
- def selected_selection_choice
1327
- matches = selection_matches
1328
- return nil if matches.empty?
1329
515
 
1330
- matches[selection_index]
1331
- end
1332
516
 
1333
- def select_previous_choice
1334
- matches = selection_matches
1335
- return if matches.empty?
1336
517
 
1337
- @select_state[:selection_index] = (selection_index - 1) % matches.length
1338
- end
1339
518
 
1340
- def select_next_choice
1341
- matches = selection_matches
1342
- return if matches.empty?
1343
519
 
1344
- @select_state[:selection_index] = (selection_index + 1) % matches.length
1345
- end
1346
520
 
1347
- def select_insert_key(key)
1348
- return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
1349
521
 
1350
- select_insert_string(key)
1351
- end
1352
522
 
1353
- def select_insert_string(string)
1354
- return if string.empty?
1355
523
 
1356
- @input = @input[0...@cursor] + string + @input[@cursor..]
1357
- @cursor += string.length
1358
- @select_state[:selection_index] = 0 if @select_state
1359
- end
1360
524
 
1361
- def select_delete_before_cursor
1362
- return unless @cursor.positive?
1363
525
 
1364
- @input = @input[0...(@cursor - 1)] + @input[@cursor..]
1365
- @cursor -= 1
1366
- @select_state[:selection_index] = 0 if @select_state
1367
- end
1368
526
 
1369
- def select_delete_at_cursor
1370
- return unless @cursor < @input.length
1371
527
 
1372
- @input = @input[0...@cursor] + @input[(@cursor + 1)..]
1373
- @select_state[:selection_index] = 0 if @select_state
1374
- end
1375
528
 
1376
- def selection_matches
1377
- choices = @select_state ? @select_state[:choices] : []
1378
- filter = @input.downcase.strip
1379
- matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
1380
- clamp_selection_index(matches.length)
1381
- matches
1382
- end
1383
529
 
1384
- def selection_index
1385
- @select_state ? @select_state[:selection_index].to_i : 0
1386
- end
1387
530
 
1388
- def clamp_selection_index(count)
1389
- return unless @select_state
1390
531
 
1391
- @select_state[:selection_index] = 0 if count <= 0
1392
- @select_state[:selection_index] = count - 1 if count.positive? && selection_index >= count
1393
- end
1394
532
 
1395
- def finish_select_prompt
1396
- @mutex.synchronize do
1397
- @select_state = nil
1398
- clear_prompt_locked
1399
- @input = ""
1400
- @cursor = 0
1401
- @asking = false
1402
- @rendered_rows = 0
1403
- @cursor_rendered_row = 0
1404
- @output_io.flush
1405
- end
1406
- end
1407
533
 
1408
- def insert_key(key)
1409
- return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
1410
534
 
1411
- insert_string(key)
1412
- end
1413
535
 
1414
- def insert_string(string)
1415
- return if string.empty?
1416
536
 
1417
- reset_slash_selection
1418
- reset_history_navigation
1419
- @slash_overlay_dismissed_input = nil
1420
- @input = @input[0...@cursor] + string + @input[@cursor..]
1421
- @cursor += string.length
1422
- end
1423
537
 
1424
- def insert_paste(string)
1425
- parsed = parse_attachments(string)
1426
- Array(parsed[:attachments]).each { |attachment| add_attachment(attachment) }
1427
- insert_string(parsed[:text].to_s) unless parsed[:text].to_s.empty?
1428
- end
1429
538
 
1430
- def parse_attachments(string)
1431
- return { text: string.to_s, attachments: [] } unless @attachment_parser
1432
539
 
1433
- result = @attachment_parser.call(string.to_s)
1434
- return { text: string.to_s, attachments: [] } unless result.is_a?(Hash)
1435
540
 
1436
- {
1437
- text: result[:text] || result["text"] || "",
1438
- attachments: result[:attachments] || result["attachments"] || []
1439
- }
1440
- rescue StandardError
1441
- { text: string.to_s, attachments: [] }
1442
- end
1443
541
 
1444
- def add_attachment(attachment)
1445
- return unless attachment.respond_to?(:key?)
1446
542
 
1447
- source = attachment[:source_text] || attachment["source_text"] || attachment[:original_path] || attachment["original_path"]
1448
- return if source.to_s.empty?
1449
- return if @attachments.any? { |item| (item[:source_text] || item["source_text"]).to_s == source.to_s }
1450
543
 
1451
- @attachments << attachment
1452
- end
1453
544
 
1454
- def delete_before_cursor
1455
- if @cursor.zero?
1456
- remove_last_attachment
1457
- return
1458
- end
1459
545
 
1460
- reset_slash_selection
1461
- reset_history_navigation
1462
- @input = @input[0...(@cursor - 1)] + @input[@cursor..]
1463
- @cursor -= 1
1464
- end
1465
546
 
1466
- def remove_last_attachment
1467
- return if @attachments.empty?
1468
547
 
1469
- reset_slash_selection
1470
- reset_history_navigation
1471
- @slash_overlay_dismissed_input = nil
1472
- @attachments.pop
1473
- end
1474
548
 
1475
- def delete_at_cursor
1476
- return unless @cursor < @input.length
1477
549
 
1478
- reset_slash_selection
1479
- reset_history_navigation
1480
- @slash_overlay_dismissed_input = nil
1481
- @input = @input[0...@cursor] + @input[(@cursor + 1)..]
1482
- end
1483
550
 
1484
- def handle_composer_key_binding(key)
1485
- case key
1486
- when "\x01"
1487
- move_to_start_of_line
1488
- when "\x02"
1489
- move_cursor_left
1490
- when "\x04"
1491
- delete_at_cursor_or_exit
1492
- when "\x05"
1493
- move_to_end_of_line
1494
- when "\x06"
1495
- move_cursor_right
1496
- when "\x0B"
1497
- kill_line_after_cursor
1498
- when "\x0C"
1499
- redraw_screen_locked
1500
- when "\x15"
1501
- kill_line_before_cursor
1502
- when "\x17"
1503
- delete_word_before_cursor
1504
- when "\x19"
1505
- yank_kill_buffer
1506
- when "\e[D", "\eOD"
1507
- move_cursor_left
1508
- when "\e[C", "\eOC"
1509
- move_cursor_right
1510
- when "\e[H", "\eOH", "\e[1~", "\e[7~"
1511
- move_to_start_of_line
1512
- when "\e[F", "\eOF", "\e[4~", "\e[8~"
1513
- move_to_end_of_line
1514
- when "\e[3~"
1515
- delete_at_cursor
1516
- when "\eb", "\eB"
1517
- move_to_previous_word
1518
- when "\ef", "\eF"
1519
- move_to_next_word
1520
- when "\ed", "\eD"
1521
- delete_word_after_cursor
1522
- when "\e\b", "\e\x7F"
1523
- delete_word_before_cursor
1524
- else
1525
- handle_modified_ansi_key(key) || false
1526
- end
1527
- end
1528
551
 
1529
- def handle_modified_ansi_key(key)
1530
- match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/)
1531
- if match
1532
- modifier = match[2].to_i
1533
- final = match[3]
1534
- return false unless alt_modifier?(modifier)
1535
-
1536
- case final
1537
- when "C"
1538
- move_to_next_word
1539
- when "D"
1540
- move_to_previous_word
1541
- when "F"
1542
- move_to_end_of_line
1543
- when "H"
1544
- move_to_start_of_line
1545
- else
1546
- false
1547
- end
1548
- elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
1549
- alt_modifier?(match[1].to_i) ? delete_word_after_cursor : delete_at_cursor
1550
- else
1551
- false
1552
- end
1553
- end
1554
552
 
1555
- def move_cursor_left
1556
- @cursor -= 1 if @cursor.positive?
1557
- end
1558
553
 
1559
- def move_cursor_right
1560
- @cursor += 1 if @cursor < @input.length
1561
- end
1562
554
 
1563
- def move_to_start_of_line
1564
- @cursor = 0
1565
- end
1566
555
 
1567
- def move_to_end_of_line
1568
- @cursor = @input.length
1569
- end
1570
556
 
1571
- def move_to_previous_word
1572
- @cursor = previous_word_boundary(@cursor)
1573
- end
1574
557
 
1575
- def move_to_next_word
1576
- @cursor = next_word_boundary(@cursor)
1577
- end
1578
558
 
1579
- def delete_at_cursor_or_exit
1580
- @input.empty? ? exit_input : delete_at_cursor
1581
- end
1582
559
 
1583
- def delete_word_before_cursor
1584
- start_index = previous_word_boundary(@cursor)
1585
- kill_range(start_index, @cursor)
1586
- end
1587
560
 
1588
- def delete_word_after_cursor
1589
- end_index = next_word_boundary(@cursor)
1590
- kill_range(@cursor, end_index)
1591
- end
1592
561
 
1593
- def kill_line_before_cursor
1594
- kill_range(0, @cursor)
1595
- end
1596
562
 
1597
- def kill_line_after_cursor
1598
- kill_range(@cursor, @input.length)
1599
- end
1600
563
 
1601
- def kill_range(start_index, end_index)
1602
- return if start_index == end_index
1603
564
 
1604
- reset_slash_selection
1605
- reset_history_navigation
1606
- @kill_buffer = @input[start_index...end_index].to_s
1607
- @input = @input[0...start_index].to_s + @input[end_index..].to_s
1608
- @cursor = start_index
1609
- end
1610
565
 
1611
- def yank_kill_buffer
1612
- insert_string(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
1613
- end
1614
566
 
1615
- def previous_word_boundary(index)
1616
- cursor = index
1617
- cursor -= 1 while cursor.positive? && word_separator?(@input[cursor - 1])
1618
- cursor -= 1 while cursor.positive? && !word_separator?(@input[cursor - 1])
1619
- cursor
1620
- end
1621
567
 
1622
- def next_word_boundary(index)
1623
- cursor = index
1624
- cursor += 1 while cursor < @input.length && word_separator?(@input[cursor])
1625
- cursor += 1 while cursor < @input.length && !word_separator?(@input[cursor])
1626
- cursor
1627
- end
1628
568
 
1629
- def word_separator?(char)
1630
- char.to_s.match?(/\s/)
1631
- end
1632
569
 
1633
- def add_history(value)
1634
- stripped = value.to_s.strip
1635
- return if stripped.empty?
1636
- return if @history.last == value
1637
570
 
1638
- @history << value
1639
- end
1640
571
 
1641
- def recall_previous_history
1642
- return if @history.empty?
1643
572
 
1644
- @history_draft = @input if @history_index.nil?
1645
- @history_index = @history_index.nil? ? @history.length - 1 : [@history_index - 1, 0].max
1646
- replace_input(@history[@history_index])
1647
- end
1648
573
 
1649
- def recall_next_history
1650
- return if @history_index.nil?
1651
574
 
1652
- if @history_index < @history.length - 1
1653
- @history_index += 1
1654
- replace_input(@history[@history_index])
1655
- else
1656
- replace_input(@history_draft || "")
1657
- reset_history_navigation
1658
- end
1659
- end
1660
575
 
1661
- def replace_input(value)
1662
- @input = value.to_s
1663
- @cursor = @input.length
1664
- end
1665
576
 
1666
- def prefill_input(value)
1667
- @mutex.synchronize do
1668
- @prefill_input = value.to_s
1669
- end
1670
- end
1671
577
 
1672
- def reset_history_navigation
1673
- @history_index = nil
1674
- @history_draft = nil
1675
- end
1676
578
 
1677
- def reset_slash_selection
1678
- @slash_selection_index = 0
1679
- end
1680
579
 
1681
- def dismiss_slash_overlay
1682
- return false unless slash_overlay_visible?
1683
580
 
1684
- @slash_overlay_dismissed_input = @input.dup
1685
- reset_slash_selection
1686
- true
1687
- end
1688
581
 
1689
- def normalize_slash_commands(commands)
1690
- commands.map do |command|
1691
- {
1692
- name: slash_command_value(command, :name).to_s,
1693
- description: slash_command_value(command, :description).to_s,
1694
- argument_hint: slash_command_value(command, :argument_hint).to_s
1695
- }
1696
- end.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
1697
- end
1698
582
 
1699
- def slash_command_value(command, key)
1700
- return command[key] if command.respond_to?(:key?) && command.key?(key)
1701
- return command[key.to_s] if command.respond_to?(:key?) && command.key?(key.to_s)
1702
- return command.public_send(key) if command.respond_to?(key)
1703
583
 
1704
- ""
1705
- end
1706
584
 
1707
- def slash_overlay_visible?
1708
- @input.match?(%r{\A/[^\s/]*\z}) && @slash_overlay_dismissed_input != @input && !slash_overlay_matches.empty?
1709
- end
1710
585
 
1711
- def slash_overlay_matches
1712
- prefix = @input.delete_prefix("/").downcase
1713
- @slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
1714
- end
1715
586
 
1716
- def selected_slash_command
1717
- return nil unless slash_overlay_visible?
1718
587
 
1719
- matches = slash_overlay_matches
1720
- return nil if matches.empty?
1721
588
 
1722
- matches[[@slash_selection_index, matches.length - 1].min]
1723
- end
1724
589
 
1725
- def select_previous_slash_command
1726
- matches = slash_overlay_matches
1727
- return if matches.empty?
1728
590
 
1729
- @slash_selection_index = (@slash_selection_index - 1) % matches.length
1730
- end
1731
591
 
1732
- def select_next_slash_command
1733
- matches = slash_overlay_matches
1734
- return if matches.empty?
1735
592
 
1736
- @slash_selection_index = (@slash_selection_index + 1) % matches.length
1737
- end
1738
593
 
1739
- def complete_selected_slash_command
1740
- command = selected_slash_command
1741
- return false unless command
1742
594
 
1743
- replace_input("/#{command[:name]} ")
1744
- reset_slash_selection
1745
- true
1746
- end
1747
595
 
1748
- def render_prompt_locked
1749
- return unless @started && @asking
1750
-
1751
- handle_resize_locked
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)
1755
- @rendered_rows = rows.length
1756
- render_composer_rows_locked(rows, height: height)
1757
- @cursor_rendered_row = cursor_row
1758
- @last_width = width
1759
- @last_height = height
1760
- move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
1761
- render_cursor_visibility_locked
1762
- @output_io.flush
1763
- end
1764
596
 
1765
- def render_prompt_after_output_locked
1766
- render_prompt_locked
1767
- end
1768
-
1769
- def clear_prompt_locked
1770
- handle_resize_locked
1771
- width, height = screen_size
1772
- clear_composer_region_locked(height: height)
1773
- @rendered_rows = 0
1774
- @cursor_rendered_row = 0
1775
- redraw_transcript_locked(width: width, height: height)
1776
- end
1777
-
1778
- def clear_prompt_for_output_locked
1779
- handle_resize_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)
1783
- @rendered_rows = 0
1784
- @cursor_rendered_row = 0
1785
- move_to_transcript_cursor_locked(width: width, height: height) if @started
1786
- end
1787
-
1788
- def prepare_transcript_output_locked
1789
- handle_resize_locked
1790
- width, height = screen_size
1791
- hide_cursor_for_transcript_output_locked
1792
- reserve_composer_region_locked(width: width, height: height)
1793
- move_to_transcript_cursor_locked(width: width, height: height)
1794
- end
1795
-
1796
- def hide_cursor_for_transcript_output_locked
1797
- return unless @started && @asking
1798
-
1799
- set_cursor_visible_locked(false)
1800
- end
1801
-
1802
- def restore_composer_cursor_locked
1803
- return unless @started && @asking
1804
597
 
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)
1808
- render_cursor_visibility_locked
1809
- end
1810
598
 
1811
- def render_cursor_visibility_locked
1812
- visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
1813
- set_cursor_visible_locked(visible)
1814
- end
1815
599
 
1816
- def set_cursor_visible_locked(visible, force: false)
1817
- return if !force && @cursor_visible == visible
1818
600
 
1819
- @output_io.print(visible ? CURSOR_SHOW : CURSOR_HIDE)
1820
- @cursor_visible = visible
1821
- end
1822
-
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)
1826
- end
1827
-
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
1831
-
1832
- old_reserved_rows = @reserved_rows
1833
- rows_to_clear = [old_reserved_rows, new_reserved_rows].max
1834
- @reserved_rows = new_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
1838
- end
1839
-
1840
- def handle_resize_locked
1841
- current_width, current_height = screen_size
1842
- return false if current_width == @last_width && current_height == @last_height
1843
-
1844
- old_width = @last_width
1845
- old_height = @last_height
1846
- old_reserved_rows = @reserved_rows
1847
- restore_scroll_region_locked
1848
- rows_to_clear = resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
1849
- clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
1850
- @reserved_rows = 0
1851
- @last_width = current_width
1852
- @last_height = current_height
1853
- redraw_screen_locked(width: current_width, height: current_height)
1854
- true
1855
- end
1856
601
 
1857
- def restore_scroll_region_locked
1858
- @output_io.print("\e[r")
1859
- @reserved_rows = 0
1860
- end
1861
602
 
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
603
 
1867
- max_rows.times do |index|
1868
- row = rows[index]
1869
- previous = @last_composer_rows[index]
1870
- next if row == previous
1871
604
 
1872
- move_to_screen(top + index, 1)
1873
- @output_io.print(TTY::Cursor.clear_line)
1874
- @output_io.print(row) unless row.to_s.empty?
1875
- end
1876
605
 
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
606
 
1882
- @last_composer_rows = rows.dup
1883
- end
1884
607
 
1885
- def clear_composer_region_locked(rows_to_clear = nil, height: screen_height)
1886
- rows_to_clear ||= [@reserved_rows, @rendered_rows].max
1887
- clear_bottom_rows_locked(height, rows_to_clear)
1888
- @last_composer_rows = []
1889
- end
1890
608
 
1891
- def resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
1892
- return old_reserved_rows unless old_reserved_rows.positive?
1893
609
 
1894
- return old_reserved_rows unless current_width < old_width
1895
610
 
1896
- wrapped_rows_per_row = ((old_width - 1) / current_width) + 1
1897
- old_reserved_rows * wrapped_rows_per_row
1898
- end
1899
611
 
1900
- def clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
1901
- return unless rows_to_clear.positive?
1902
612
 
1903
- old_top = [old_height - rows_to_clear + 1, 1].max
1904
- current_top = [current_height - rows_to_clear + 1, 1].max
1905
- clear_screen_rows_locked([old_top, current_top].min, current_height)
1906
- end
1907
613
 
1908
- def clear_bottom_rows_locked(height, rows_to_clear)
1909
- return unless rows_to_clear.positive?
1910
614
 
1911
- bottom = height
1912
- top = [bottom - rows_to_clear + 1, 1].max
1913
- clear_screen_rows_locked(top, bottom)
1914
- end
1915
615
 
1916
- def clear_screen_rows_locked(top, bottom)
1917
- top.upto(bottom) do |row|
1918
- move_to_screen(row, 1)
1919
- @output_io.print(TTY::Cursor.clear_line)
1920
- end
1921
- end
1922
616
 
1923
- def redraw_screen_locked(width: screen_width, height: screen_height)
1924
- return unless @started
1925
617
 
1926
- restore_scroll_region_locked
1927
- @output_io.print(TTY::Cursor.clear_screen)
1928
- move_to_screen(1, 1)
1929
- @reserved_rows = 0
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)
1934
- @rendered_rows = @asking ? rows.length : 0
1935
- render_composer_rows_locked(rows, height: height) if @asking
1936
- @cursor_rendered_row = @asking ? cursor_row : 0
1937
- @last_width = width
1938
- @last_height = height
1939
- reset_stream_position_from_transcript_locked(width)
1940
- if @asking
1941
- move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
1942
- render_cursor_visibility_locked
1943
- end
1944
- end
1945
618
 
1946
- def redraw_transcript_locked(width: screen_width, height: screen_height)
1947
- return unless transcript_renderable?
1948
619
 
1949
- rows = transcript_viewport_rows(transcript_redraw_row_count(height), width)
1950
- clear_screen_rows_locked(1, rows.length)
1951
- return if rows.empty?
1952
620
 
1953
- move_to_screen(1, 1)
1954
- @output_io.print(terminal_newlines(rows.join("\n")))
1955
- end
1956
621
 
1957
- def transcript_viewport_text(row_count, width)
1958
- transcript_viewport_rows(row_count, width).join("\n")
1959
- end
1960
622
 
1961
- def transcript_viewport_rows(row_count, width)
1962
- return [] unless row_count.positive?
1963
623
 
1964
- rows = transcript_display_rows(width).last(row_count)
1965
- rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
1966
- rows
1967
- end
1968
624
 
1969
- def transcript_redraw_row_count(height = screen_height)
1970
- [[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
1971
- end
1972
625
 
1973
- def remember_transcript_viewport_locked(height = screen_height)
1974
- @transcript_viewport_rows = transcript_bottom_row(height)
1975
- end
1976
626
 
1977
- def transcript_renderable?
1978
- @visual_banner_count.positive? || !@transcript_buffer.empty?
1979
- end
1980
627
 
1981
- def transcript_display_rows(width)
1982
- return @transcript_display_rows_cache if @transcript_display_rows_cache_width == width && @transcript_display_rows_cache
1983
628
 
1984
- rows = []
1985
- @visual_banner_count.times { rows.concat(banner_rows(width)) }
1986
- rows << "" if @visual_banner_count.positive? && @transcript_buffer.empty?
1987
- rows.concat(transcript_text_display_rows(width))
1988
- @transcript_display_rows_cache_width = width
1989
- @transcript_display_rows_cache = rows
1990
- end
1991
629
 
1992
- def transcript_text_display_rows(width)
1993
- @transcript_buffer.split(/\r\n|\r|\n/, -1).flat_map do |line|
1994
- chunks = ANSI.wrap_visible(line, width)
1995
- chunks.empty? ? [""] : chunks
1996
- end
1997
- end
1998
630
 
1999
- def reset_stream_position_from_transcript_locked(width = screen_width)
2000
- rows = transcript_display_rows(width)
2001
- last_length = rows.empty? ? 0 : ANSI.strip(rows.last).length
2002
- if last_length >= width
2003
- @stream_col = 0
2004
- @stream_pending_wrap = true
2005
- else
2006
- @stream_col = last_length
2007
- @stream_pending_wrap = false
2008
- end
2009
- end
2010
631
 
2011
- def move_to_transcript_cursor_locked(width: screen_width, height: screen_height)
2012
- if @stream_pending_wrap
2013
- move_to_screen(transcript_bottom_row(height), width)
2014
- else
2015
- move_to_screen(transcript_bottom_row(height), [@stream_col + 1, width].min)
2016
- end
2017
- end
2018
632
 
2019
- def advance_pending_stream_wrap_locked(output_text, width: screen_width, height: screen_height)
2020
- return unless @stream_pending_wrap
2021
- return if output_text.empty? || output_text.start_with?("\r", "\n")
2022
633
 
2023
- move_to_screen(transcript_bottom_row(height), width)
2024
- @output_io.print("\r\n")
2025
- @stream_col = 0
2026
- @stream_pending_wrap = false
2027
- end
2028
634
 
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
2032
-
2033
- content_width = [width - 4, 1].max
2034
- input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
2035
- attachment_rows = attachment_badge_rows(content_width)
2036
- overlay_rows = active_overlay_rows(width, height: height)
2037
- footer_text = footer_text()
2038
- max_input_rows = max_visible_input_rows(attachment_rows.length, overlay_rows.length, footer_text.empty? ? 0 : 1, height: height)
2039
- visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
2040
- visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
2041
- rows = overlay_rows + [top_border(width)]
2042
- rows.concat(attachment_rows)
2043
- rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
2044
- rows << footer_row(content_width, footer_text) unless footer_text.empty?
2045
- rows << bottom_border(width)
2046
- cursor_row = overlay_rows.length + 1 + attachment_rows.length + input_cursor_row - visible_start
2047
- cursor_col = 2 + [input_cursor_col, content_width - 1].min
2048
- [rows, cursor_row, cursor_col]
2049
- end
2050
635
 
2051
- def question_composer_layout(width, height = screen_height)
2052
- content_width = [width - 4, 1].max
2053
- overlay_rows = active_overlay_rows(width, height: height)
2054
- rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
2055
- return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
2056
636
 
2057
- [rows, overlay_rows.length + 1, 2]
2058
- end
2059
637
 
2060
- def active_overlay_rows(width, height: screen_height)
2061
- return question_overlay_rows(width) if @question_state
2062
- return selection_overlay_rows(width, height: height) if @select_state
2063
638
 
2064
- slash_overlay_rows(width, height: height)
2065
- end
2066
639
 
2067
- def banner_rows(width)
2068
- @banner.rows(width)
2069
- end
2070
640
 
2071
- def banner_logo_rows
2072
- @banner.logo_rows(screen_width)
2073
- end
2074
641
 
2075
- def question_overlay_rows(width)
2076
- title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
2077
- lines = [
2078
- overlay_text_line(@question_state[:question].to_s, :bold),
2079
- overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
2080
- overlay_blank_line
2081
- ]
2082
- question_choices.each_with_index do |choice, index|
2083
- selected = index == question_selection_index
2084
- lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
2085
- end
2086
- overlay_card_rows(title, lines, width)
2087
- end
2088
642
 
2089
- def slash_overlay_rows(width, height: screen_height)
2090
- return [] unless slash_overlay_visible?
2091
643
 
2092
- visible = visible_slash_overlay_matches(slash_overlay_matches, height: height)
2093
- start_index = visible[:start]
2094
- lines = visible[:commands].each_with_index.map do |command, offset|
2095
- index = start_index + offset
2096
- hint = command[:argument_hint].empty? ? "" : " #{command[:argument_hint]}"
2097
- description = command[:description].empty? ? "" : " — #{command[:description]}"
2098
- overlay_choice_line("/#{command[:name]}#{hint}#{description}", selected: index == @slash_selection_index)
2099
- end
2100
- overlay_card_rows("Slash commands", lines, width)
2101
- end
2102
644
 
2103
- def visible_slash_overlay_matches(matches, height: screen_height)
2104
- max_rows = [[height - 7, 1].max, 8].min
2105
- start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
2106
- { start: start, commands: matches[start, max_rows] || [] }
2107
- end
2108
645
 
2109
- def selection_overlay_rows(width, height: screen_height)
2110
- matches = selection_matches
2111
- lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
2112
- if matches.empty?
2113
- if @select_state && @select_state[:custom] && !@input.strip.empty?
2114
- lines << overlay_choice_line("Use custom: #{@input.strip}", selected: true)
2115
- else
2116
- lines << overlay_text_line("No matches", :muted)
2117
- end
2118
- return overlay_card_rows(selection_overlay_title, lines, width)
2119
- end
2120
646
 
2121
- visible = visible_selection_matches(matches, height: height)
2122
- start_index = visible[:start]
2123
- visible[:choices].each_with_index do |choice, offset|
2124
- index = start_index + offset
2125
- lines << overlay_choice_line(choice, selected: index == selection_index)
2126
- end
2127
- overlay_card_rows(selection_overlay_title, lines, width)
2128
- end
2129
647
 
2130
- def selection_overlay_title
2131
- title = @select_state && @select_state[:title].to_s
2132
- title && !title.empty? ? title : "Sessions"
2133
- end
2134
648
 
2135
- def visible_selection_matches(matches, height: screen_height)
2136
- max_rows = [[height - 7, 1].max, 8].min
2137
- start = [[selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
2138
- { start: start, choices: matches[start, max_rows] || [] }
2139
- end
2140
649
 
2141
- def question_custom_cursor_row
2142
- 4 + question_choices.index { |choice| choice[:custom] }.to_i
2143
- end
2144
650
 
2145
- def question_custom_cursor_col(width)
2146
- card_width = overlay_card_width(width)
2147
- left_padding = overlay_left_padding(width, card_width)
2148
- custom_prefix = selected_question_choice&.fetch(:custom, false) || !@input.empty? ? "Type something: " : "Type something."
2149
- visible_before_cursor = display_question_input(@input[0...@cursor])
2150
- [[left_padding + 2 + 2 + custom_prefix.length + visible_before_cursor.length, width - 1].min, 0].max
2151
- end
2152
651
 
2153
- def choice_text(choice, selected: false)
2154
- if choice[:custom]
2155
- if selected || !@input.empty?
2156
- "Type something: #{display_question_input(@input)}"
2157
- else
2158
- "Type something."
2159
- end
2160
- else
2161
- description = choice[:description].empty? ? "" : " — #{choice[:description]}"
2162
- "#{choice[:label]}#{description}"
2163
- end
2164
- end
2165
652
 
2166
- def display_question_input(value)
2167
- value.to_s.gsub(/\s+/, " ").strip
2168
- end
2169
653
 
2170
- def overlay_card_rows(title, content_rows, width)
2171
- card_width = overlay_card_width(width)
2172
- inner_width = [card_width - 4, 1].max
2173
- rows = [overlay_top_border(title, card_width)]
2174
- rows.concat(content_rows.map { |row| overlay_content_row(row, inner_width) })
2175
- rows << overlay_bottom_border(card_width)
2176
- rows.map { |row| align_overlay_row(row, width) }
2177
- end
2178
654
 
2179
- def overlay_card_width(width)
2180
- return width if width < 32
2181
- return width if @overlay_settings["width"] == "maximum"
2182
655
 
2183
- [[width - 4, 32].max, 96].min
2184
- end
2185
656
 
2186
- def overlay_top_border(title, card_width)
2187
- title = visible_truncate(title.to_s, [card_width - 4, 1].max)
2188
- plain_length = ANSI.strip(title).length
2189
- colored("╭", :primary_green) + " #{colored(title, :bright_accent_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
2190
- end
2191
657
 
2192
- def overlay_bottom_border(card_width)
2193
- colored("╰#{"─" * [card_width - 2, 0].max}╯", :primary_green)
2194
- end
2195
658
 
2196
- def overlay_content_row(row, inner_width)
2197
- text = visible_truncate(row[:text], inner_width)
2198
- text = colored(text, :bright_accent_green, :bold) if row[:selected]
2199
- colored("│", :primary_green) + " " + visible_ljust(text, inner_width) + " " + colored("│", :primary_green)
2200
- end
2201
659
 
2202
- def overlay_text_line(text, style = nil)
2203
- rendered = case style
2204
- when :bold
2205
- colored(text.to_s, :bold)
2206
- when :muted
2207
- colored(text.to_s, :gray)
2208
- else
2209
- text.to_s
2210
- end
2211
- { text: rendered }
2212
- end
2213
660
 
2214
- def overlay_blank_line
2215
- { text: "" }
2216
- end
2217
661
 
2218
- def overlay_choice_line(text, selected: false)
2219
- { text: "#{selected ? "›" : " "} #{text}", selected: selected }
2220
- end
2221
662
 
2222
- def align_overlay_row(row, width)
2223
- plain_length = ANSI.strip(row).length
2224
- padding = [width - plain_length, 0].max
2225
- left = overlay_left_padding(width, plain_length)
2226
- right = padding - left
2227
- (" " * left) + row + (" " * right)
2228
- end
2229
663
 
2230
- def overlay_left_padding(width, row_width)
2231
- padding = [width - row_width, 0].max
2232
- case @overlay_settings["alignment"]
2233
- when "left"
2234
- 0
2235
- when "right"
2236
- padding
2237
- else
2238
- padding / 2
2239
- end
2240
- end
2241
664
 
2242
- def normalize_overlay_settings(settings)
2243
- values = { "alignment" => "center", "width" => "capped" }
2244
- source = settings.is_a?(Hash) ? settings : {}
2245
- alignment = (source[:alignment] || source["alignment"]).to_s
2246
- width = (source[:width] || source["width"]).to_s
2247
- values["alignment"] = alignment if %w[left center right].include?(alignment)
2248
- values["width"] = width if %w[capped maximum].include?(width)
2249
- values
2250
- end
2251
665
 
2252
- def visible_ljust(text, width)
2253
- text.to_s + (" " * [width - ANSI.strip(text.to_s).length, 0].max)
2254
- end
2255
666
 
2256
- def visible_truncate(text, width)
2257
- plain = ANSI.strip(text.to_s)
2258
- return text.to_s if plain.length <= width
2259
667
 
2260
- plain[0, width]
2261
- end
2262
668
 
2263
- def compact_composer_layout(width)
2264
- cursor_line, cursor_col = cursor_logical_position
2265
- prefix = "#{@prompt_label} "
2266
- line = input_lines[cursor_line] || ""
2267
- input_width = [width - prefix.length, 1].max
2268
- visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
2269
- visible = line[visible_start, input_width].to_s
2270
- row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
2271
- [[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
2272
- end
2273
669
 
2274
- def input_layout(content_width)
2275
- cursor_line, cursor_col = cursor_logical_position
2276
- rows = []
2277
- cursor_row = 0
2278
- rendered_row_offset = 0
2279
-
2280
- input_lines.each_with_index do |line, index|
2281
- prefix = input_prefix(index)
2282
- continuation_prefix = " " * prefix.length
2283
- available = [content_width - prefix.length, 1].max
2284
- chunks = line.scan(/.{1,#{available}}/m)
2285
- chunks = [""] if chunks.empty?
2286
- if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
2287
- chunks << ""
2288
- end
2289
670
 
2290
- if index == cursor_line
2291
- cursor_row = rendered_row_offset + (cursor_col / available)
2292
- end
2293
671
 
2294
- chunks.each_with_index do |chunk, chunk_index|
2295
- rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
2296
- end
2297
- rendered_row_offset += chunks.length
2298
- end
2299
672
 
2300
- prefix = input_prefix(cursor_line)
2301
- available = [content_width - prefix.length, 1].max
2302
- cursor_col_in_row = prefix.length + (cursor_col % available)
2303
- [rows, cursor_row, cursor_col_in_row]
2304
- end
2305
673
 
2306
- def top_border(width)
2307
- title = composer_title
2308
- status = composer_status_text
2309
- if status
2310
- gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
2311
- if gap >= 0
2312
- return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
2313
- end
2314
- end
2315
- plain_title = ANSI.strip(title)
2316
- "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
2317
- end
2318
674
 
2319
- def composer_title
2320
- label = @prompt_label.delete_suffix(">")
2321
- if @busy && @queued_count.positive?
2322
- status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
2323
- elsif @busy && @steered_count.to_i.positive?
2324
- status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
2325
- elsif @busy
2326
- status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
2327
- else
2328
- status_composer_text(label)
2329
- end
2330
- end
2331
675
 
2332
- def busy_title(text)
2333
- @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
2334
- end
2335
676
 
2336
- def composer_status_text
2337
- text = @composer_status&.call.to_s
2338
- return nil if text.empty?
2339
677
 
2340
- status_composer_text(text)
2341
- end
2342
678
 
2343
- def status_composer_text(text)
2344
- " #{text} "
2345
- end
2346
679
 
2347
- def bottom_border(width)
2348
- colored("╰#{"─" * [width - 2, 0].max}╯", :primary_green)
2349
- end
2350
680
 
2351
- def box_content_row(row, content_width)
2352
- "#{colored("│", :primary_green)} #{row[0, content_width].to_s.ljust(content_width)} #{colored("│", :primary_green)}"
2353
- end
2354
681
 
2355
- def footer_row(content_width, text = footer_text)
2356
- return nil if text.empty?
2357
682
 
2358
- box_content_row(visible_truncate(text, content_width), content_width)
2359
- end
2360
683
 
2361
- def footer_text
2362
- return "" unless @footer
2363
684
 
2364
- @footer.call.to_s.gsub(/\s+/, " ").strip
2365
- rescue StandardError
2366
- ""
2367
- end
2368
685
 
2369
- def attachment_badge_rows(content_width)
2370
- attachment_badge_texts.map { |text| box_content_row(visible_truncate(text, content_width), content_width) }
2371
- end
2372
686
 
2373
- def attachment_badge_texts
2374
- return [] unless @attachment_badges
2375
687
 
2376
- Array(@attachment_badges.call(@input, @attachments)).map(&:to_s).reject(&:empty?)
2377
- rescue ArgumentError
2378
- Array(@attachment_badges.call(@input)).map(&:to_s).reject(&:empty?)
2379
- rescue StandardError
2380
- []
2381
- end
2382
688
 
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)
2384
- input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
2385
- [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
2386
- end
2387
689
 
2388
- def composer_top_row(height = screen_height)
2389
- [height - @reserved_rows + 1, 1].max
2390
- end
2391
690
 
2392
- def transcript_bottom_row(height = screen_height)
2393
- [height - @reserved_rows, 1].max
2394
- end
2395
691
 
2396
- def move_to_screen(row, col)
2397
- @output_io.print("\e[#{row};#{col}H")
2398
- end
2399
692
 
2400
- def input_lines
2401
- lines = @input.split("\n", -1)
2402
- lines.empty? ? [""] : lines
2403
- end
2404
693
 
2405
- def input_prefix(_index)
2406
- ""
2407
- end
2408
694
 
2409
- def cursor_logical_position
2410
- before_cursor = @input[0...@cursor]
2411
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
2412
- end
2413
695
 
2414
- def update_stream_position(text, width: screen_width)
2415
- ANSI.strip(text).each_char do |char|
2416
- case char
2417
- when "\n", "\r"
2418
- @stream_col = 0
2419
- @stream_pending_wrap = false
2420
- else
2421
- @stream_pending_wrap = false
2422
- @stream_col += 1
2423
- if @stream_col >= width
2424
- @stream_col = 0
2425
- @stream_pending_wrap = true
2426
- end
2427
- end
2428
- end
2429
- end
2430
696
 
2431
- def colored(text, *styles)
2432
- ANSI.colorize(text, *styles, enabled: @color_enabled)
2433
- end
2434
697
 
2435
- def transcript_label(label)
2436
- label == "Assistant" ? @assistant_label : label
2437
- end
2438
698
 
2439
- def label_color(label)
2440
- case label
2441
- when "Reasoning"
2442
- :yellow
2443
- when "Assistant", "Kward"
2444
- :green
2445
- when "Tool"
2446
- :magenta
2447
- when "Tool output"
2448
- :cyan
2449
- else
2450
- :blue
2451
- end
2452
- end
2453
699
 
2454
- def screen_size
2455
- [screen_width, screen_height]
2456
- end
2457
700
 
2458
- def screen_width
2459
- [TTY::Screen.width, 1].max
2460
- end
2461
701
 
2462
- def screen_height
2463
- [TTY::Screen.height, 2].max
2464
- end
2465
702
  end
2466
703
  end