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