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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +8 -2
- data/README.md +37 -30
- data/Rakefile +14 -1
- data/doc/authentication.md +84 -43
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +27 -2
- data/doc/extensibility.md +90 -129
- data/doc/getting-started.md +53 -57
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -99
- data/doc/releasing.md +10 -9
- data/doc/rpc.md +7 -7
- data/doc/usage.md +125 -141
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +30 -3
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +229 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +227 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +134 -0
- data/lib/kward/cli/rendering.rb +378 -0
- data/lib/kward/cli/runtime_helpers.rb +170 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +669 -0
- data/lib/kward/cli/slash_commands.rb +114 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +157 -0
- data/lib/kward/cli.rb +52 -2792
- data/lib/kward/cli_transcript_formatter.rb +40 -12
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +31 -9
- data/lib/kward/config_files.rb +78 -34
- data/lib/kward/conversation.rb +110 -13
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +144 -14
- data/lib/kward/message_access.rb +29 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +14 -10
- data/lib/kward/model/model_info.rb +160 -4
- data/lib/kward/model/payloads.rb +254 -22
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +387 -25
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +63 -7
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +43 -11
- data/lib/kward/rpc/session_manager.rb +139 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +50 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +154 -25
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +3 -2
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +42 -4
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +20 -17
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +27 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- 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
|