kward 0.67.1 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -0,0 +1,328 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Structured question overlay used by ask_user_question.
4
+ class PromptInterface
5
+ # Structured question overlay used by the ask-user-question tool.
6
+ module QuestionPrompt
7
+ private
8
+
9
+ def ask_single_user_question(question, index, total)
10
+ @mutex.synchronize do
11
+ @prompt_label = "Answer>"
12
+ self.composer_input = ""
13
+ self.composer_cursor = 0
14
+ @pending_keys.clear
15
+ @asking = true
16
+ @busy = false
17
+ @queued_count = 0
18
+ @question_state = {
19
+ question: question[:question] || question["question"],
20
+ header: question[:header] || question["header"],
21
+ options: question[:options] || question["options"],
22
+ selection_index: 0,
23
+ index: index,
24
+ total: total
25
+ }
26
+ reset_history_navigation
27
+ render_prompt_locked
28
+ end
29
+
30
+ loop do
31
+ key = read_key(nonblock: true)
32
+ result = nil
33
+ @mutex.synchronize do
34
+ if key.nil?
35
+ resized = handle_resize_locked
36
+ footer_refreshed = tick_footer_locked
37
+ render_prompt_locked if resized || footer_refreshed
38
+ else
39
+ result = handle_question_key(key)
40
+ render_prompt_locked unless result.is_a?(Hash) || result == SELECT_CANCEL
41
+ end
42
+ end
43
+
44
+ return result if result.is_a?(Hash) || result == SELECT_CANCEL
45
+
46
+ sleep 0.02 if key.nil?
47
+ end
48
+ end
49
+
50
+ def begin_question_prompt_state
51
+ {
52
+ prompt_label: @prompt_label,
53
+ input: composer_input,
54
+ cursor: composer_cursor,
55
+ asking: @asking,
56
+ busy: @busy,
57
+ queued_count: @queued_count,
58
+ steered_count: @steered_count,
59
+ pending_keys: @pending_keys.dup,
60
+ select_state: @select_state
61
+ }
62
+ end
63
+
64
+ def finish_question_prompt(saved_state)
65
+ @mutex.synchronize do
66
+ @question_state = nil
67
+ @select_state = saved_state[:select_state]
68
+ @prompt_label = saved_state[:prompt_label]
69
+ self.composer_input = saved_state[:input]
70
+ self.composer_cursor = saved_state[:cursor]
71
+ @asking = saved_state[:asking]
72
+ @busy = saved_state[:busy]
73
+ @queued_count = saved_state[:queued_count]
74
+ @steered_count = saved_state[:steered_count]
75
+ @pending_keys = saved_state[:pending_keys]
76
+ render_prompt_locked if @started && @asking
77
+ @output_io.flush
78
+ end
79
+ end
80
+
81
+ def handle_question_key(key)
82
+ return if handle_question_bracketed_paste_key(key)
83
+
84
+ csi_result = handle_question_csi_u_key(key)
85
+ return csi_result unless csi_result == false
86
+
87
+ if key.is_a?(String) && key.length > 1
88
+ token = next_key_token(key)
89
+ if token.length < key.length
90
+ queue_pending_keys(key[token.length..])
91
+ return handle_question_key(token)
92
+ end
93
+ end
94
+
95
+ key_name = @reader.console.keys[key]
96
+ case key_name
97
+ when :return, :enter
98
+ current_question_answer
99
+ when :backspace
100
+ question_delete_before_cursor
101
+ when :delete
102
+ question_delete_at_cursor
103
+ when :left
104
+ self.composer_cursor -= 1 if composer_cursor.positive?
105
+ when :right
106
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
107
+ when :home
108
+ self.composer_cursor = 0
109
+ when :end
110
+ self.composer_cursor = composer_input.length
111
+ when :up
112
+ question_previous_choice
113
+ when :down
114
+ question_next_choice
115
+ else
116
+ case key
117
+ when "\n", "\r"
118
+ current_question_answer
119
+ when "\b", "\x7F"
120
+ question_delete_before_cursor
121
+ when "\e"
122
+ handle_question_escape_sequence
123
+ else
124
+ question_insert_key(key)
125
+ end
126
+ end
127
+ end
128
+
129
+ def handle_question_csi_u_key(key)
130
+ match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
131
+ return false unless match
132
+
133
+ sequence = match[0]
134
+ code = match[1].to_i
135
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
136
+
137
+ case code
138
+ when 13
139
+ current_question_answer
140
+ when 27
141
+ SELECT_CANCEL
142
+ when 8, 127
143
+ question_delete_before_cursor
144
+ nil
145
+ else
146
+ false
147
+ end
148
+ end
149
+
150
+ def handle_question_escape_sequence
151
+ sequence = read_pending_escape_sequence
152
+ return SELECT_CANCEL if sequence.empty?
153
+
154
+ key_name = @reader.console.keys["\e#{sequence}"]
155
+ case key_name
156
+ when :up
157
+ question_previous_choice
158
+ when :down
159
+ question_next_choice
160
+ when :left
161
+ self.composer_cursor -= 1 if composer_cursor.positive?
162
+ when :right
163
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
164
+ end
165
+ true
166
+ end
167
+
168
+ def handle_question_bracketed_paste_key(key)
169
+ text = key.to_s
170
+ return false unless text.start_with?(BRACKETED_PASTE_START)
171
+
172
+ pasted = text[BRACKETED_PASTE_START.length..] || ""
173
+ until pasted.include?(BRACKETED_PASTE_END)
174
+ chunk = @reader.read_keypress(echo: false, raw: true)
175
+ break if chunk.nil?
176
+
177
+ pasted << chunk.to_s
178
+ end
179
+
180
+ content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
181
+ question_insert_string(normalize_paste(content || ""))
182
+ queue_pending_keys(remaining) if remaining && !remaining.empty?
183
+ true
184
+ end
185
+
186
+ def current_question_answer
187
+ choice = selected_question_choice
188
+ return nil unless choice
189
+
190
+ if choice[:custom]
191
+ answer = composer_input.strip
192
+ return nil if answer.empty?
193
+
194
+ { question: current_question_text, answer: answer, custom: true }
195
+ else
196
+ { question: current_question_text, answer: choice[:label], custom: false }
197
+ end
198
+ end
199
+
200
+ def selected_question_choice
201
+ choices = question_choices
202
+ return nil if choices.empty?
203
+
204
+ choices[question_selection_index]
205
+ end
206
+
207
+ def question_choices
208
+ options = Array(@question_state ? @question_state[:options] : []).map do |option|
209
+ { label: (option[:label] || option["label"]).to_s, description: (option[:description] || option["description"]).to_s }
210
+ end
211
+ choices = options + [{ label: "Type something.", description: composer_input.strip, custom: true }]
212
+ clamp_question_selection_index(choices.length)
213
+ choices
214
+ end
215
+
216
+ def current_question_text
217
+ (@question_state && @question_state[:question]).to_s
218
+ end
219
+
220
+ def question_selection_index
221
+ @question_state ? @question_state[:selection_index].to_i : 0
222
+ end
223
+
224
+ def clamp_question_selection_index(count)
225
+ return unless @question_state
226
+
227
+ @question_state[:selection_index] = 0 if count <= 0
228
+ @question_state[:selection_index] = count - 1 if count.positive? && question_selection_index >= count
229
+ end
230
+
231
+ def question_previous_choice
232
+ choices = question_choices
233
+ return if choices.empty?
234
+
235
+ @question_state[:selection_index] = (question_selection_index - 1) % choices.length
236
+ end
237
+
238
+ def question_next_choice
239
+ choices = question_choices
240
+ return if choices.empty?
241
+
242
+ @question_state[:selection_index] = (question_selection_index + 1) % choices.length
243
+ end
244
+
245
+ def question_insert_key(key)
246
+ return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
247
+
248
+ question_insert_string(key)
249
+ end
250
+
251
+ def question_insert_string(string)
252
+ return if string.empty?
253
+
254
+ self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
255
+ self.composer_cursor += string.length
256
+ @question_state[:selection_index] = question_choices.length - 1 if @question_state
257
+ end
258
+
259
+ def question_delete_before_cursor
260
+ return unless composer_cursor.positive?
261
+
262
+ self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
263
+ self.composer_cursor -= 1
264
+ @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
265
+ end
266
+
267
+ def question_delete_at_cursor
268
+ return unless composer_cursor < composer_input.length
269
+
270
+ self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
271
+ @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
272
+ end
273
+
274
+ def question_composer_layout(width, height = screen_height)
275
+ content_width = [width - 4, 1].max
276
+ overlay_rows = active_overlay_rows(width, height: height)
277
+ rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
278
+ return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
279
+
280
+ [rows, overlay_rows.length + 1, 2]
281
+ end
282
+
283
+ def question_overlay_rows(width)
284
+ title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
285
+ lines = [
286
+ overlay_text_line(@question_state[:question].to_s, :bold),
287
+ overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
288
+ overlay_blank_line
289
+ ]
290
+ question_choices.each_with_index do |choice, index|
291
+ selected = index == question_selection_index
292
+ lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
293
+ end
294
+ overlay_card_rows(title, lines, width)
295
+ end
296
+
297
+ def question_custom_cursor_row
298
+ 4 + question_choices.index { |choice| choice[:custom] }.to_i
299
+ end
300
+
301
+ def question_custom_cursor_col(width)
302
+ card_width = overlay_card_width(width)
303
+ left_padding = overlay_left_padding(width, card_width)
304
+ custom_prefix = selected_question_choice&.fetch(:custom, false) || !composer_input.empty? ? "Type something: " : "Type something."
305
+ visible_before_cursor = display_question_input(composer_input[0...composer_cursor])
306
+ [[left_padding + 2 + 2 + custom_prefix.length + visible_before_cursor.length, width - 1].min, 0].max
307
+ end
308
+
309
+ def choice_text(choice, selected: false)
310
+ if choice[:custom]
311
+ if selected || !composer_input.empty?
312
+ "Type something: #{display_question_input(composer_input)}"
313
+ else
314
+ "Type something."
315
+ end
316
+ else
317
+ description = choice[:description].empty? ? "" : " — #{choice[:description]}"
318
+ "#{choice[:label]}#{description}"
319
+ end
320
+ end
321
+
322
+ def display_question_input(value)
323
+ value.to_s.gsub(/\s+/, " ")
324
+ end
325
+
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,59 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Runtime metadata shown in the prompt footer and status UI.
4
+ class PromptInterface
5
+ # Runtime footer, model, persona, and session state shown by the prompt interface.
6
+ module RuntimeState
7
+ private
8
+
9
+ def reset_spinner_locked
10
+ @spinner_frame_index = 0
11
+ @last_spinner_tick = monotonic_now
12
+ end
13
+
14
+ def normalize_busy_activity(activity)
15
+ text = activity.to_s.gsub(/\s+/, " ").strip
16
+ text.empty? ? "streaming" : text
17
+ end
18
+
19
+ def tick_spinner_locked
20
+ return false unless @busy && @queued_count.zero? && @started && @asking
21
+
22
+ now = monotonic_now
23
+ elapsed = now - @last_spinner_tick
24
+ return false if elapsed < SPINNER_INTERVAL
25
+
26
+ steps = (elapsed / SPINNER_INTERVAL).floor
27
+ @spinner_frame_index = (@spinner_frame_index + steps) % SPINNER_FRAMES.length
28
+ @last_spinner_tick += steps * SPINNER_INTERVAL
29
+ true
30
+ end
31
+
32
+ def spinner_frame
33
+ SPINNER_FRAMES[@spinner_frame_index % SPINNER_FRAMES.length]
34
+ end
35
+
36
+ def tick_footer_locked
37
+ return false unless @footer && @started && @asking
38
+
39
+ now = monotonic_now
40
+ elapsed = now - @last_footer_refresh
41
+ return false if elapsed < FOOTER_REFRESH_INTERVAL
42
+
43
+ steps = (elapsed / FOOTER_REFRESH_INTERVAL).floor
44
+ @last_footer_refresh += steps * FOOTER_REFRESH_INTERVAL
45
+ true
46
+ end
47
+
48
+ def monotonic_now
49
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ end
51
+
52
+
53
+ def colored(text, *styles)
54
+ ANSI.colorize(text, *styles, enabled: @color_enabled)
55
+ end
56
+
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,186 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Terminal screen lifecycle and escape-sequence management.
4
+ class PromptInterface
5
+ # Terminal screen control and escape-sequence helpers.
6
+ module Screen
7
+ private
8
+
9
+ def enter_raw_mode_locked
10
+ return unless @input_io.respond_to?(:tty?) && @input_io.tty?
11
+ return unless @input_io.respond_to?(:console_mode) && @input_io.respond_to?(:console_mode=)
12
+ return if @raw_mode_active
13
+
14
+ @original_console_mode = @input_io.console_mode
15
+ raw_mode = @input_io.console_mode.raw
16
+ raw_mode.echo = false
17
+ @input_io.console_mode = raw_mode
18
+ @raw_mode_active = true
19
+ rescue StandardError
20
+ @original_console_mode = nil
21
+ @raw_mode_active = false
22
+ end
23
+
24
+ def restore_console_mode_locked
25
+ return unless @raw_mode_active
26
+
27
+ @input_io.console_mode = @original_console_mode if @original_console_mode
28
+ ensure
29
+ @original_console_mode = nil
30
+ @raw_mode_active = false
31
+ end
32
+
33
+ def with_synchronized_output_locked
34
+ if @restoring_transcript || @synchronized_output_depth.positive?
35
+ yield
36
+ return
37
+ end
38
+
39
+ synchronized = true
40
+ @synchronized_output_depth += 1
41
+ @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
42
+ yield
43
+ ensure
44
+ if synchronized
45
+ @synchronized_output_depth -= 1
46
+ @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE) if @synchronized_output_depth.zero?
47
+ end
48
+ end
49
+
50
+ def hide_cursor_for_transcript_output_locked
51
+ return unless @started && @asking
52
+
53
+ set_cursor_visible_locked(false)
54
+ end
55
+
56
+ def render_cursor_visibility_locked
57
+ visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
58
+ set_cursor_visible_locked(visible)
59
+ end
60
+
61
+ def set_cursor_visible_locked(visible, force: false)
62
+ return if !force && @cursor_visible == visible
63
+
64
+ @output_io.print(visible ? CURSOR_SHOW : CURSOR_HIDE)
65
+ @cursor_visible = visible
66
+ end
67
+
68
+ def reserve_composer_region_locked(width: screen_width, height: screen_height)
69
+ rows, = composer_layout(width, height)
70
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
71
+ end
72
+
73
+ def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
74
+ new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
75
+ return if @reserved_rows == new_reserved_rows && @last_height == height
76
+
77
+ old_reserved_rows = @reserved_rows
78
+ rows_to_clear = [old_reserved_rows, new_reserved_rows].max
79
+ @reserved_rows = new_reserved_rows
80
+ @output_io.print("\e[1;#{transcript_bottom_row(height)}r")
81
+ clear_composer_region_locked(rows_to_clear, height: height)
82
+ redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
83
+ end
84
+
85
+ def handle_resize_locked
86
+ current_width, current_height = screen_size
87
+ return false if current_width == @last_width && current_height == @last_height
88
+
89
+ old_width = @last_width
90
+ old_height = @last_height
91
+ old_reserved_rows = @reserved_rows
92
+ restore_scroll_region_locked
93
+ rows_to_clear = resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
94
+ clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
95
+ @reserved_rows = 0
96
+ @last_width = current_width
97
+ @last_height = current_height
98
+ redraw_screen_locked(width: current_width, height: current_height)
99
+ true
100
+ end
101
+
102
+ def restore_scroll_region_locked
103
+ @output_io.print("\e[r")
104
+ @reserved_rows = 0
105
+ end
106
+
107
+ def render_composer_rows_locked(rows, height: screen_height)
108
+ top = composer_top_row(height)
109
+ max_rows = [@last_composer_rows.length, rows.length].max
110
+ rows_to_clear = [@reserved_rows - rows.length, 0].max
111
+
112
+ max_rows.times do |index|
113
+ row = rows[index]
114
+ previous = @last_composer_rows[index]
115
+ next if row == previous
116
+
117
+ move_to_screen(top + index, 1)
118
+ @output_io.print(TTY::Cursor.clear_line)
119
+ @output_io.print(row) unless row.to_s.empty?
120
+ end
121
+
122
+ rows.length.upto(rows.length + rows_to_clear - 1) do |index|
123
+ move_to_screen(top + index, 1)
124
+ @output_io.print(TTY::Cursor.clear_line)
125
+ end
126
+
127
+ @last_composer_rows = rows.dup
128
+ end
129
+
130
+ def clear_composer_region_locked(rows_to_clear = nil, height: screen_height)
131
+ rows_to_clear ||= [@reserved_rows, @rendered_rows].max
132
+ clear_bottom_rows_locked(height, rows_to_clear)
133
+ @last_composer_rows = []
134
+ end
135
+
136
+ def resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
137
+ return old_reserved_rows unless old_reserved_rows.positive?
138
+
139
+ return old_reserved_rows unless current_width < old_width
140
+
141
+ wrapped_rows_per_row = ((old_width - 1) / current_width) + 1
142
+ old_reserved_rows * wrapped_rows_per_row
143
+ end
144
+
145
+ def clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
146
+ return unless rows_to_clear.positive?
147
+
148
+ old_top = [old_height - rows_to_clear + 1, 1].max
149
+ current_top = [current_height - rows_to_clear + 1, 1].max
150
+ clear_screen_rows_locked([old_top, current_top].min, current_height)
151
+ end
152
+
153
+ def clear_bottom_rows_locked(height, rows_to_clear)
154
+ return unless rows_to_clear.positive?
155
+
156
+ bottom = height
157
+ top = [bottom - rows_to_clear + 1, 1].max
158
+ clear_screen_rows_locked(top, bottom)
159
+ end
160
+
161
+ def clear_screen_rows_locked(top, bottom)
162
+ top.upto(bottom) do |row|
163
+ move_to_screen(row, 1)
164
+ @output_io.print(TTY::Cursor.clear_line)
165
+ end
166
+ end
167
+
168
+ def move_to_screen(row, col)
169
+ @output_io.print("\e[#{row};#{col}H")
170
+ end
171
+
172
+ def screen_size
173
+ [screen_width, screen_height]
174
+ end
175
+
176
+ def screen_width
177
+ [TTY::Screen.width, 1].max
178
+ end
179
+
180
+ def screen_height
181
+ [TTY::Screen.height, 2].max
182
+ end
183
+
184
+ end
185
+ end
186
+ end