kward 0.67.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -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
@@ -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+/, " ").strip
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