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,365 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Keyboard sequence dispatcher for composer and overlay input.
4
+ class PromptInterface
5
+ # Keyboard sequence handling for the terminal prompt interface.
6
+ module KeyHandler
7
+ private
8
+
9
+ def read_key(nonblock: false)
10
+ pending = @pending_keys.shift unless @pending_keys.empty?
11
+ return pending if pending
12
+
13
+ @reader.read_keypress(echo: false, raw: true, nonblock: nonblock)
14
+ rescue TTY::Reader::InputInterrupt
15
+ "\x03"
16
+ rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
17
+ nil
18
+ end
19
+
20
+ def handle_key(key)
21
+ return submit_input if key.nil?
22
+ return if handle_bracketed_paste_key(key)
23
+
24
+ csi_result = handle_csi_u_key(key)
25
+ return csi_result unless csi_result == false
26
+ return if handle_shift_enter_key(key)
27
+ if key.is_a?(String) && key.length > 1
28
+ token = next_key_token(key)
29
+ if token.length < key.length
30
+ queue_pending_keys(key[token.length..])
31
+ return handle_key(token)
32
+ end
33
+ end
34
+
35
+ binding_result = handle_composer_key_binding(key)
36
+ return binding_result unless binding_result == false
37
+
38
+ key_name = @reader.console.keys[key]
39
+ case key_name
40
+ when :return, :enter
41
+ submit_input
42
+ when :backspace
43
+ delete_before_cursor
44
+ when :delete
45
+ delete_at_cursor
46
+ when :ctrl_d
47
+ delete_at_cursor_or_exit
48
+ when :ctrl_c
49
+ cancel_input_or_interrupt
50
+ when :ctrl_a
51
+ move_to_start_of_line
52
+ when :ctrl_e
53
+ move_to_end_of_line
54
+ when :ctrl_b
55
+ move_cursor_left
56
+ when :ctrl_f
57
+ move_cursor_right
58
+ when :ctrl_w
59
+ delete_word_before_cursor
60
+ when :ctrl_u
61
+ kill_line_before_cursor
62
+ when :ctrl_k
63
+ kill_line_after_cursor
64
+ when :ctrl_y
65
+ yank_kill_buffer
66
+ when :ctrl_l
67
+ redraw_screen_locked
68
+ when :left
69
+ move_cursor_left
70
+ when :right
71
+ move_cursor_right
72
+ when :home
73
+ move_to_start_of_line
74
+ when :end
75
+ move_to_end_of_line
76
+ when :up
77
+ slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
78
+ when :down
79
+ slash_overlay_visible? ? select_next_slash_command : recall_next_history
80
+ else
81
+ case key
82
+ when "\n", "\r"
83
+ submit_input
84
+ when "\t"
85
+ complete_selected_slash_command || insert_key(key)
86
+ when "\b", "\x7F"
87
+ delete_before_cursor
88
+ when "\x04"
89
+ delete_at_cursor_or_exit
90
+ when "\x03"
91
+ cancel_input_or_interrupt
92
+ when "\e"
93
+ handle_escape_sequence
94
+ else
95
+ insert_key(key)
96
+ end
97
+ end
98
+ end
99
+
100
+ def cancel_input_or_interrupt
101
+ return CANCEL_INPUT if @busy
102
+
103
+ raise Interrupt
104
+ end
105
+
106
+ def handle_escape_sequence
107
+ pending_sequence = read_pending_escape_sequence
108
+ return true if pending_sequence.empty? && dismiss_slash_overlay
109
+
110
+ full_sequence = "\e#{pending_sequence}"
111
+ sequence = next_key_token(full_sequence)
112
+ queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
113
+ return true if sequence == "\e" && dismiss_slash_overlay
114
+ return true if handle_shift_enter_key(sequence)
115
+
116
+ binding_result = handle_composer_key_binding(sequence)
117
+ return binding_result unless binding_result == false
118
+
119
+ key_name = @reader.console.keys[sequence]
120
+ case key_name
121
+ when :up
122
+ slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
123
+ when :down
124
+ slash_overlay_visible? ? select_next_slash_command : recall_next_history
125
+ when :left
126
+ move_cursor_left
127
+ when :right
128
+ move_cursor_right
129
+ when :home
130
+ move_to_start_of_line
131
+ when :end
132
+ move_to_end_of_line
133
+ when :delete
134
+ delete_at_cursor
135
+ end
136
+ true
137
+ end
138
+
139
+ def handle_bracketed_paste_key(key)
140
+ text = key.to_s
141
+ return false unless text.start_with?(BRACKETED_PASTE_START)
142
+
143
+ pasted = text[BRACKETED_PASTE_START.length..] || ""
144
+ until pasted.include?(BRACKETED_PASTE_END)
145
+ chunk = @reader.read_keypress(echo: false, raw: true)
146
+ break if chunk.nil?
147
+
148
+ pasted << chunk.to_s
149
+ end
150
+
151
+ content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
152
+ insert_paste(normalize_paste(content || ""))
153
+ queue_pending_keys(remaining) if remaining && !remaining.empty?
154
+ true
155
+ end
156
+
157
+ def normalize_paste(content)
158
+ content.gsub("\r\n", "\n").gsub("\r", "\n")
159
+ end
160
+
161
+ def handle_csi_u_key(key)
162
+ match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
163
+ return false unless match
164
+
165
+ sequence = match[0]
166
+ code = match[1].to_i
167
+ modifier = (match[2] || "1").split(":", 2).first.to_i
168
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
169
+
170
+ case code
171
+ when 13
172
+ modifier == 2 ? insert_string("\n") : submit_input
173
+ when 27
174
+ dismiss_slash_overlay || false
175
+ when 8, 127
176
+ alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
177
+ nil
178
+ when 4
179
+ delete_at_cursor_or_exit
180
+ else
181
+ handle_modified_csi_u_key(code, modifier)
182
+ end
183
+ end
184
+
185
+ def handle_modified_csi_u_key(code, modifier)
186
+ return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
187
+
188
+ normalized_code = code.to_i.chr.downcase.ord rescue code
189
+ if ctrl_modifier?(modifier)
190
+ case normalized_code
191
+ when 97
192
+ move_to_start_of_line
193
+ when 98
194
+ move_cursor_left
195
+ when 99
196
+ cancel_input_or_interrupt
197
+ when 100
198
+ delete_at_cursor_or_exit
199
+ when 101
200
+ move_to_end_of_line
201
+ when 102
202
+ move_cursor_right
203
+ when 104
204
+ delete_before_cursor
205
+ when 107
206
+ kill_line_after_cursor
207
+ when 108
208
+ redraw_screen_locked
209
+ when 117
210
+ kill_line_before_cursor
211
+ when 119
212
+ delete_word_before_cursor
213
+ when 121
214
+ yank_kill_buffer
215
+ else
216
+ false
217
+ end
218
+ elsif alt_modifier?(modifier)
219
+ case normalized_code
220
+ when 98
221
+ move_to_previous_word
222
+ when 100
223
+ delete_word_after_cursor
224
+ when 102
225
+ move_to_next_word
226
+ else
227
+ false
228
+ end
229
+ else
230
+ false
231
+ end
232
+ end
233
+
234
+ def ctrl_modifier?(modifier)
235
+ ((modifier.to_i - 1) & 4).positive?
236
+ end
237
+
238
+ def alt_modifier?(modifier)
239
+ ((modifier.to_i - 1) & 2).positive?
240
+ end
241
+
242
+ def handle_shift_enter_key(key)
243
+ sequence = shift_enter_sequence_for(key)
244
+ return false unless sequence
245
+
246
+ insert_string("\n")
247
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
248
+ true
249
+ end
250
+
251
+ def queue_pending_keys(keys)
252
+ remaining = keys.to_s
253
+ until remaining.empty?
254
+ token = next_key_token(remaining)
255
+ @pending_keys << token
256
+ remaining = remaining[token.length..] || ""
257
+ end
258
+ end
259
+
260
+ def next_key_token(keys)
261
+ text = keys.to_s
262
+ text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
263
+ text.match(/\A\eO[A-Za-z]/)&.[](0) ||
264
+ shift_enter_sequence_for(text) ||
265
+ (text.start_with?("\e") && text.length > 1 && alt_key_sequence?(text[1]) ? text[0, 2] : text[0, 1])
266
+ end
267
+
268
+ def alt_key_sequence?(char)
269
+ char = char.to_s
270
+ char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
271
+ end
272
+
273
+ def shift_enter_sequence_for(key)
274
+ return nil unless key.is_a?(String)
275
+
276
+ SHIFT_ENTER_SEQUENCES.find { |sequence| key.start_with?(sequence) }
277
+ end
278
+
279
+ def read_pending_escape_sequence
280
+ sequence = +""
281
+ until @pending_keys.empty?
282
+ sequence << @pending_keys.shift.to_s
283
+ end
284
+ while (char = @reader.read_keypress(echo: false, raw: true, nonblock: true))
285
+ sequence << char.to_s
286
+ end
287
+ sequence
288
+ rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
289
+ sequence
290
+ end
291
+
292
+ def handle_composer_key_binding(key)
293
+ case key
294
+ when "\x01"
295
+ move_to_start_of_line
296
+ when "\x02"
297
+ move_cursor_left
298
+ when "\x04"
299
+ delete_at_cursor_or_exit
300
+ when "\x05"
301
+ move_to_end_of_line
302
+ when "\x06"
303
+ move_cursor_right
304
+ when "\x0B"
305
+ kill_line_after_cursor
306
+ when "\x0C"
307
+ redraw_screen_locked
308
+ when "\x15"
309
+ kill_line_before_cursor
310
+ when "\x17"
311
+ delete_word_before_cursor
312
+ when "\x19"
313
+ yank_kill_buffer
314
+ when "\e[D", "\eOD"
315
+ move_cursor_left
316
+ when "\e[C", "\eOC"
317
+ move_cursor_right
318
+ when "\e[H", "\eOH", "\e[1~", "\e[7~"
319
+ move_to_start_of_line
320
+ when "\e[F", "\eOF", "\e[4~", "\e[8~"
321
+ move_to_end_of_line
322
+ when "\e[3~"
323
+ delete_at_cursor
324
+ when "\eb", "\eB"
325
+ move_to_previous_word
326
+ when "\ef", "\eF"
327
+ move_to_next_word
328
+ when "\ed", "\eD"
329
+ delete_word_after_cursor
330
+ when "\e\b", "\e\x7F"
331
+ delete_word_before_cursor
332
+ else
333
+ handle_modified_ansi_key(key) || false
334
+ end
335
+ end
336
+
337
+ def handle_modified_ansi_key(key)
338
+ match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/)
339
+ if match
340
+ modifier = match[2].to_i
341
+ final = match[3]
342
+ return false unless alt_modifier?(modifier)
343
+
344
+ case final
345
+ when "C"
346
+ move_to_next_word
347
+ when "D"
348
+ move_to_previous_word
349
+ when "F"
350
+ move_to_end_of_line
351
+ when "H"
352
+ move_to_start_of_line
353
+ else
354
+ false
355
+ end
356
+ elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
357
+ alt_modifier?(match[1].to_i) ? delete_word_after_cursor : delete_at_cursor
358
+ else
359
+ false
360
+ end
361
+ end
362
+
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,31 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Layout calculations for terminal rows and overlay placement.
4
+ class PromptInterface
5
+ # Terminal layout calculations for transcript, overlays, footer, and composer.
6
+ module Layout
7
+ private
8
+
9
+ def banner_rows(width)
10
+ @banner.rows(width)
11
+ end
12
+
13
+ def banner_logo_rows
14
+ @banner.logo_rows(screen_width)
15
+ end
16
+
17
+ def transcript_redraw_row_count(height = screen_height)
18
+ [[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
19
+ end
20
+
21
+ def composer_top_row(height = screen_height)
22
+ [height - @reserved_rows + 1, 1].max
23
+ end
24
+
25
+ def transcript_bottom_row(height = screen_height)
26
+ [height - @reserved_rows, 1].max
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,111 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Shared overlay drawing helpers for prompt UI popups.
4
+ class PromptInterface
5
+ # Renderer for selection, slash-command, and question overlays.
6
+ module OverlayRenderer
7
+ private
8
+
9
+ def active_overlay_rows(width, height: screen_height)
10
+ return question_overlay_rows(width) if @question_state
11
+ return selection_overlay_rows(width, height: height) if @select_state
12
+
13
+ slash_overlay_rows(width, height: height)
14
+ end
15
+
16
+ def overlay_card_rows(title, content_rows, width)
17
+ card_width = overlay_card_width(width)
18
+ inner_width = [card_width - 4, 1].max
19
+ rows = [overlay_top_border(title, card_width)]
20
+ rows.concat(content_rows.map { |row| overlay_content_row(row, inner_width) })
21
+ rows << overlay_bottom_border(card_width)
22
+ rows.map { |row| align_overlay_row(row, width) }
23
+ end
24
+
25
+ def overlay_card_width(width)
26
+ return width if width < 32
27
+ return width if @overlay_settings["width"] == "maximum"
28
+
29
+ [[width - 4, 32].max, 96].min
30
+ end
31
+
32
+ def overlay_top_border(title, card_width)
33
+ title = visible_truncate(title.to_s, [card_width - 4, 1].max)
34
+ plain_length = ANSI.strip(title).length
35
+ colored("╭", :primary_green) + " #{colored(title, :bright_accent_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
36
+ end
37
+
38
+ def overlay_bottom_border(card_width)
39
+ colored("╰#{"─" * [card_width - 2, 0].max}╯", :primary_green)
40
+ end
41
+
42
+ def overlay_content_row(row, inner_width)
43
+ text = visible_truncate(row[:text], inner_width)
44
+ text = colored(text, :bright_accent_green, :bold) if row[:selected]
45
+ colored("│", :primary_green) + " " + visible_ljust(text, inner_width) + " " + colored("│", :primary_green)
46
+ end
47
+
48
+ def overlay_text_line(text, style = nil)
49
+ rendered = case style
50
+ when :bold
51
+ colored(text.to_s, :bold)
52
+ when :muted
53
+ colored(text.to_s, :gray)
54
+ else
55
+ text.to_s
56
+ end
57
+ { text: rendered }
58
+ end
59
+
60
+ def overlay_blank_line
61
+ { text: "" }
62
+ end
63
+
64
+ def overlay_choice_line(text, selected: false)
65
+ { text: "#{selected ? "›" : " "} #{text}", selected: selected }
66
+ end
67
+
68
+ def align_overlay_row(row, width)
69
+ plain_length = ANSI.strip(row).length
70
+ padding = [width - plain_length, 0].max
71
+ left = overlay_left_padding(width, plain_length)
72
+ right = padding - left
73
+ (" " * left) + row + (" " * right)
74
+ end
75
+
76
+ def overlay_left_padding(width, row_width)
77
+ padding = [width - row_width, 0].max
78
+ case @overlay_settings["alignment"]
79
+ when "left"
80
+ 0
81
+ when "right"
82
+ padding
83
+ else
84
+ padding / 2
85
+ end
86
+ end
87
+
88
+ def normalize_overlay_settings(settings)
89
+ values = { "alignment" => "center", "width" => "capped" }
90
+ source = settings.is_a?(Hash) ? settings : {}
91
+ alignment = (source[:alignment] || source["alignment"]).to_s
92
+ width = (source[:width] || source["width"]).to_s
93
+ values["alignment"] = alignment if %w[left center right].include?(alignment)
94
+ values["width"] = width if %w[capped maximum].include?(width)
95
+ values
96
+ end
97
+
98
+ def visible_ljust(text, width)
99
+ text.to_s + (" " * [width - ANSI.strip(text.to_s).length, 0].max)
100
+ end
101
+
102
+ def visible_truncate(text, width)
103
+ plain = ANSI.strip(text.to_s)
104
+ return text.to_s if plain.length <= width
105
+
106
+ plain[0, width]
107
+ end
108
+
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,91 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Prompt label and composer chrome renderer.
4
+ class PromptInterface
5
+ # Renderer for prompt labels and composer prompt chrome.
6
+ module PromptRenderer
7
+ private
8
+
9
+ def render_prompt_locked
10
+ return unless @started && @asking
11
+
12
+ handle_resize_locked
13
+ width, height = screen_size
14
+ rows, cursor_row, cursor_col = composer_layout(width, height)
15
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
16
+ @rendered_rows = rows.length
17
+ render_composer_rows_locked(rows, height: height)
18
+ @cursor_rendered_row = cursor_row
19
+ @last_width = width
20
+ @last_height = height
21
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
22
+ render_cursor_visibility_locked
23
+ @output_io.flush
24
+ end
25
+
26
+ def render_prompt_after_output_locked
27
+ render_prompt_locked
28
+ end
29
+
30
+ def clear_prompt_locked
31
+ handle_resize_locked
32
+ width, height = screen_size
33
+ clear_composer_region_locked(height: height)
34
+ @rendered_rows = 0
35
+ @cursor_rendered_row = 0
36
+ redraw_transcript_locked(width: width, height: height)
37
+ end
38
+
39
+ def clear_prompt_for_output_locked
40
+ handle_resize_locked
41
+ width, height = screen_size
42
+ reserve_composer_region_locked(width: width, height: height) if @started && @asking
43
+ clear_composer_region_locked(height: height)
44
+ @rendered_rows = 0
45
+ @cursor_rendered_row = 0
46
+ move_to_transcript_cursor_locked(width: width, height: height) if @started
47
+ end
48
+
49
+ def prepare_transcript_output_locked
50
+ handle_resize_locked
51
+ width, height = screen_size
52
+ hide_cursor_for_transcript_output_locked
53
+ reserve_composer_region_locked(width: width, height: height)
54
+ move_to_transcript_cursor_locked(width: width, height: height)
55
+ end
56
+
57
+ def restore_composer_cursor_locked
58
+ return unless @started && @asking
59
+
60
+ width, height = screen_size
61
+ _rows, cursor_row, cursor_col = composer_layout(width, height)
62
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
63
+ render_cursor_visibility_locked
64
+ end
65
+
66
+ def redraw_screen_locked(width: screen_width, height: screen_height)
67
+ return unless @started
68
+
69
+ restore_scroll_region_locked
70
+ @output_io.print(TTY::Cursor.clear_screen)
71
+ move_to_screen(1, 1)
72
+ @reserved_rows = 0
73
+ @last_composer_rows = []
74
+ rows, cursor_row, cursor_col = composer_layout(width, height)
75
+ ensure_scroll_region_locked(rows.length, redraw_transcript: false, width: width, height: height)
76
+ redraw_transcript_locked(width: width, height: height)
77
+ @rendered_rows = @asking ? rows.length : 0
78
+ render_composer_rows_locked(rows, height: height) if @asking
79
+ @cursor_rendered_row = @asking ? cursor_row : 0
80
+ @last_width = width
81
+ @last_height = height
82
+ reset_stream_position_from_transcript_locked(width)
83
+ if @asking
84
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
85
+ render_cursor_visibility_locked
86
+ end
87
+ end
88
+
89
+ end
90
+ end
91
+ end