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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- 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
|