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,242 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Selection overlay implementation for list-style prompts.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Selection-list overlay support for prompt choices.
|
|
6
|
+
module SelectionPrompt
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_select_key(key)
|
|
10
|
+
return select_current_choice if key.nil?
|
|
11
|
+
return if handle_select_bracketed_paste_key(key)
|
|
12
|
+
|
|
13
|
+
csi_result = handle_select_csi_u_key(key)
|
|
14
|
+
return csi_result unless csi_result == false
|
|
15
|
+
|
|
16
|
+
if key.is_a?(String) && key.length > 1
|
|
17
|
+
token = next_key_token(key)
|
|
18
|
+
if token.length < key.length
|
|
19
|
+
queue_pending_keys(key[token.length..])
|
|
20
|
+
return handle_select_key(token)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
key_name = @reader.console.keys[key]
|
|
25
|
+
case key_name
|
|
26
|
+
when :return, :enter
|
|
27
|
+
select_current_choice
|
|
28
|
+
when :backspace
|
|
29
|
+
select_delete_before_cursor
|
|
30
|
+
when :delete
|
|
31
|
+
select_delete_at_cursor
|
|
32
|
+
when :left
|
|
33
|
+
self.composer_cursor -= 1 if composer_cursor.positive?
|
|
34
|
+
when :right
|
|
35
|
+
self.composer_cursor += 1 if composer_cursor < composer_input.length
|
|
36
|
+
when :home
|
|
37
|
+
self.composer_cursor = 0
|
|
38
|
+
when :end
|
|
39
|
+
self.composer_cursor = composer_input.length
|
|
40
|
+
when :up
|
|
41
|
+
select_previous_choice
|
|
42
|
+
when :down
|
|
43
|
+
select_next_choice
|
|
44
|
+
else
|
|
45
|
+
case key
|
|
46
|
+
when "\n", "\r"
|
|
47
|
+
select_current_choice
|
|
48
|
+
when "\b", "\x7F"
|
|
49
|
+
select_delete_before_cursor
|
|
50
|
+
when "\e"
|
|
51
|
+
handle_select_escape_sequence
|
|
52
|
+
else
|
|
53
|
+
select_insert_key(key)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_select_csi_u_key(key)
|
|
59
|
+
match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
|
|
60
|
+
return false unless match
|
|
61
|
+
|
|
62
|
+
sequence = match[0]
|
|
63
|
+
code = match[1].to_i
|
|
64
|
+
queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
|
|
65
|
+
|
|
66
|
+
case code
|
|
67
|
+
when 13
|
|
68
|
+
select_current_choice
|
|
69
|
+
when 27
|
|
70
|
+
SELECT_CANCEL
|
|
71
|
+
when 8, 127
|
|
72
|
+
select_delete_before_cursor
|
|
73
|
+
nil
|
|
74
|
+
else
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_select_escape_sequence
|
|
80
|
+
sequence = read_pending_escape_sequence
|
|
81
|
+
return SELECT_CANCEL if sequence.empty?
|
|
82
|
+
|
|
83
|
+
key_name = @reader.console.keys["\e#{sequence}"]
|
|
84
|
+
case key_name
|
|
85
|
+
when :up
|
|
86
|
+
select_previous_choice
|
|
87
|
+
when :down
|
|
88
|
+
select_next_choice
|
|
89
|
+
when :left
|
|
90
|
+
self.composer_cursor -= 1 if composer_cursor.positive?
|
|
91
|
+
when :right
|
|
92
|
+
self.composer_cursor += 1 if composer_cursor < composer_input.length
|
|
93
|
+
end
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_select_bracketed_paste_key(key)
|
|
98
|
+
text = key.to_s
|
|
99
|
+
return false unless text.start_with?(BRACKETED_PASTE_START)
|
|
100
|
+
|
|
101
|
+
pasted = text[BRACKETED_PASTE_START.length..] || ""
|
|
102
|
+
until pasted.include?(BRACKETED_PASTE_END)
|
|
103
|
+
chunk = @reader.read_keypress(echo: false, raw: true)
|
|
104
|
+
break if chunk.nil?
|
|
105
|
+
|
|
106
|
+
pasted << chunk.to_s
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
|
|
110
|
+
select_insert_string(normalize_paste(content || ""))
|
|
111
|
+
queue_pending_keys(remaining) if remaining && !remaining.empty?
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def select_current_choice
|
|
116
|
+
selected_selection_choice || custom_selection_choice || SELECT_CANCEL
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def custom_selection_choice
|
|
120
|
+
return nil unless @select_state && @select_state[:custom]
|
|
121
|
+
|
|
122
|
+
value = composer_input.strip
|
|
123
|
+
value.empty? ? nil : value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def selected_selection_choice
|
|
127
|
+
matches = selection_matches
|
|
128
|
+
return nil if matches.empty?
|
|
129
|
+
|
|
130
|
+
matches[selection_index]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def select_previous_choice
|
|
134
|
+
matches = selection_matches
|
|
135
|
+
return if matches.empty?
|
|
136
|
+
|
|
137
|
+
@select_state[:selection_index] = (selection_index - 1) % matches.length
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def select_next_choice
|
|
141
|
+
matches = selection_matches
|
|
142
|
+
return if matches.empty?
|
|
143
|
+
|
|
144
|
+
@select_state[:selection_index] = (selection_index + 1) % matches.length
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def select_insert_key(key)
|
|
148
|
+
return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
|
|
149
|
+
|
|
150
|
+
select_insert_string(key)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def select_insert_string(string)
|
|
154
|
+
return if string.empty?
|
|
155
|
+
|
|
156
|
+
self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
|
|
157
|
+
self.composer_cursor += string.length
|
|
158
|
+
@select_state[:selection_index] = 0 if @select_state
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def select_delete_before_cursor
|
|
162
|
+
return unless composer_cursor.positive?
|
|
163
|
+
|
|
164
|
+
self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
|
|
165
|
+
self.composer_cursor -= 1
|
|
166
|
+
@select_state[:selection_index] = 0 if @select_state
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def select_delete_at_cursor
|
|
170
|
+
return unless composer_cursor < composer_input.length
|
|
171
|
+
|
|
172
|
+
self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
|
|
173
|
+
@select_state[:selection_index] = 0 if @select_state
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def selection_matches
|
|
177
|
+
choices = @select_state ? @select_state[:choices] : []
|
|
178
|
+
filter = composer_input.downcase.strip
|
|
179
|
+
matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
|
|
180
|
+
clamp_selection_index(matches.length)
|
|
181
|
+
matches
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def selection_index
|
|
185
|
+
@select_state ? @select_state[:selection_index].to_i : 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def clamp_selection_index(count)
|
|
189
|
+
return unless @select_state
|
|
190
|
+
|
|
191
|
+
@select_state[:selection_index] = 0 if count <= 0
|
|
192
|
+
@select_state[:selection_index] = count - 1 if count.positive? && selection_index >= count
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def finish_select_prompt
|
|
196
|
+
@mutex.synchronize do
|
|
197
|
+
@select_state = nil
|
|
198
|
+
clear_prompt_locked
|
|
199
|
+
self.composer_input = ""
|
|
200
|
+
self.composer_cursor = 0
|
|
201
|
+
@asking = false
|
|
202
|
+
@rendered_rows = 0
|
|
203
|
+
@cursor_rendered_row = 0
|
|
204
|
+
@output_io.flush
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def selection_overlay_rows(width, height: screen_height)
|
|
209
|
+
matches = selection_matches
|
|
210
|
+
lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
|
|
211
|
+
if matches.empty?
|
|
212
|
+
if @select_state && @select_state[:custom] && !composer_input.strip.empty?
|
|
213
|
+
lines << overlay_choice_line("Use custom: #{composer_input.strip}", selected: true)
|
|
214
|
+
else
|
|
215
|
+
lines << overlay_text_line("No matches", :muted)
|
|
216
|
+
end
|
|
217
|
+
return overlay_card_rows(selection_overlay_title, lines, width)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
visible = visible_selection_matches(matches, height: height)
|
|
221
|
+
start_index = visible[:start]
|
|
222
|
+
visible[:choices].each_with_index do |choice, offset|
|
|
223
|
+
index = start_index + offset
|
|
224
|
+
lines << overlay_choice_line(choice, selected: index == selection_index)
|
|
225
|
+
end
|
|
226
|
+
overlay_card_rows(selection_overlay_title, lines, width)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def selection_overlay_title
|
|
230
|
+
title = @select_state && @select_state[:title].to_s
|
|
231
|
+
title && !title.empty? ? title : "Sessions"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def visible_selection_matches(matches, height: screen_height)
|
|
235
|
+
max_rows = [[height - 7, 1].max, 8].min
|
|
236
|
+
start = [[selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
|
|
237
|
+
{ start: start, choices: matches[start, max_rows] || [] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Slash-command completion overlay behavior.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Slash-command completion overlay support.
|
|
6
|
+
module SlashOverlay
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def reset_slash_selection
|
|
10
|
+
@slash_selection_index = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dismiss_slash_overlay
|
|
14
|
+
return false unless slash_overlay_visible?
|
|
15
|
+
|
|
16
|
+
@slash_overlay_dismissed_input = composer_input.dup
|
|
17
|
+
reset_slash_selection
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def normalize_slash_commands(commands)
|
|
22
|
+
commands.map do |command|
|
|
23
|
+
{
|
|
24
|
+
name: slash_command_value(command, :name).to_s,
|
|
25
|
+
description: slash_command_value(command, :description).to_s,
|
|
26
|
+
argument_hint: slash_command_value(command, :argument_hint).to_s
|
|
27
|
+
}
|
|
28
|
+
end.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def slash_command_value(command, key)
|
|
32
|
+
return command[key] if command.respond_to?(:key?) && command.key?(key)
|
|
33
|
+
return command[key.to_s] if command.respond_to?(:key?) && command.key?(key.to_s)
|
|
34
|
+
return command.public_send(key) if command.respond_to?(key)
|
|
35
|
+
|
|
36
|
+
""
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def slash_overlay_visible?
|
|
40
|
+
composer_input.match?(%r{\A/[^\s/]*\z}) && @slash_overlay_dismissed_input != composer_input && !slash_overlay_matches.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def slash_overlay_matches
|
|
44
|
+
prefix = composer_input.delete_prefix("/").downcase
|
|
45
|
+
@slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def selected_slash_command
|
|
49
|
+
return nil unless slash_overlay_visible?
|
|
50
|
+
|
|
51
|
+
matches = slash_overlay_matches
|
|
52
|
+
return nil if matches.empty?
|
|
53
|
+
|
|
54
|
+
matches[[@slash_selection_index, matches.length - 1].min]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def select_previous_slash_command
|
|
58
|
+
matches = slash_overlay_matches
|
|
59
|
+
return if matches.empty?
|
|
60
|
+
|
|
61
|
+
@slash_selection_index = (@slash_selection_index - 1) % matches.length
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def select_next_slash_command
|
|
65
|
+
matches = slash_overlay_matches
|
|
66
|
+
return if matches.empty?
|
|
67
|
+
|
|
68
|
+
@slash_selection_index = (@slash_selection_index + 1) % matches.length
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def complete_selected_slash_command
|
|
72
|
+
command = selected_slash_command
|
|
73
|
+
return false unless command
|
|
74
|
+
|
|
75
|
+
replace_input("/#{command[:name]} ")
|
|
76
|
+
reset_slash_selection
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def slash_overlay_rows(width, height: screen_height)
|
|
81
|
+
return [] unless slash_overlay_visible?
|
|
82
|
+
|
|
83
|
+
visible = visible_slash_overlay_matches(slash_overlay_matches, height: height)
|
|
84
|
+
start_index = visible[:start]
|
|
85
|
+
lines = visible[:commands].each_with_index.map do |command, offset|
|
|
86
|
+
index = start_index + offset
|
|
87
|
+
hint = command[:argument_hint].empty? ? "" : " #{command[:argument_hint]}"
|
|
88
|
+
description = command[:description].empty? ? "" : " — #{command[:description]}"
|
|
89
|
+
overlay_choice_line("/#{command[:name]}#{hint}#{description}", selected: index == @slash_selection_index)
|
|
90
|
+
end
|
|
91
|
+
overlay_card_rows("Slash commands", lines, width)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def visible_slash_overlay_matches(matches, height: screen_height)
|
|
95
|
+
max_rows = [[height - 7, 1].max, 8].min
|
|
96
|
+
start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
|
|
97
|
+
{ start: start, commands: matches[start, max_rows] || [] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Cursor/column state for streamed assistant blocks.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# State object for streamed assistant output blocks.
|
|
6
|
+
class StreamState
|
|
7
|
+
attr_reader :block, :col
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
reset
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reset
|
|
14
|
+
@block = nil
|
|
15
|
+
@col = 0
|
|
16
|
+
@pending_wrap = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start_block(label)
|
|
20
|
+
@block = label
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def finish_block
|
|
24
|
+
@block = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pending_wrap?
|
|
28
|
+
@pending_wrap
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_position_from_rows(rows, width)
|
|
32
|
+
last_length = rows.empty? ? 0 : ANSI.strip(rows.last).length
|
|
33
|
+
if last_length >= width
|
|
34
|
+
@col = 0
|
|
35
|
+
@pending_wrap = true
|
|
36
|
+
else
|
|
37
|
+
@col = last_length
|
|
38
|
+
@pending_wrap = false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_pending_wrap
|
|
43
|
+
@col = 0
|
|
44
|
+
@pending_wrap = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update_position(text, width:)
|
|
48
|
+
ANSI.strip(text).each_char do |char|
|
|
49
|
+
case char
|
|
50
|
+
when "\n", "\r"
|
|
51
|
+
@col = 0
|
|
52
|
+
@pending_wrap = false
|
|
53
|
+
else
|
|
54
|
+
@pending_wrap = false
|
|
55
|
+
@col += 1
|
|
56
|
+
if @col >= width
|
|
57
|
+
@col = 0
|
|
58
|
+
@pending_wrap = true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Bounded text buffer for transcript rendering.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Bounded in-memory transcript buffer used by the prompt interface.
|
|
6
|
+
class TranscriptBuffer
|
|
7
|
+
attr_reader :text
|
|
8
|
+
|
|
9
|
+
def initialize(limit:)
|
|
10
|
+
@limit = limit
|
|
11
|
+
@text = +""
|
|
12
|
+
@display_rows_cache_width = nil
|
|
13
|
+
@display_rows_cache_banner_count = nil
|
|
14
|
+
@display_rows_cache = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_s
|
|
18
|
+
@text
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def include?(*arguments)
|
|
22
|
+
@text.include?(*arguments)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty?
|
|
26
|
+
@text.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def end_with?(*suffixes)
|
|
30
|
+
@text.end_with?(*suffixes)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear
|
|
34
|
+
@text = +""
|
|
35
|
+
invalidate_display_rows_cache
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def append(text)
|
|
39
|
+
@text << ANSI.sanitize_transcript(text)
|
|
40
|
+
@text = @text[-@limit, @limit] if @text.length > @limit
|
|
41
|
+
invalidate_display_rows_cache
|
|
42
|
+
@text
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def viewport_text(row_count, width, visual_banner_count:, banner_rows:)
|
|
46
|
+
viewport_rows(row_count, width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def viewport_rows(row_count, width, visual_banner_count:, banner_rows:)
|
|
50
|
+
return [] unless row_count.positive?
|
|
51
|
+
|
|
52
|
+
rows = display_rows(width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).last(row_count)
|
|
53
|
+
rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
|
|
54
|
+
rows
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def display_rows(width, visual_banner_count:, banner_rows:)
|
|
58
|
+
if @display_rows_cache_width == width && @display_rows_cache_banner_count == visual_banner_count && @display_rows_cache
|
|
59
|
+
return @display_rows_cache
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
rows = []
|
|
63
|
+
visual_banner_count.times { rows.concat(banner_rows.call(width)) }
|
|
64
|
+
rows << "" if visual_banner_count.positive? && @text.empty?
|
|
65
|
+
rows.concat(text_display_rows(width))
|
|
66
|
+
@display_rows_cache_width = width
|
|
67
|
+
@display_rows_cache_banner_count = visual_banner_count
|
|
68
|
+
@display_rows_cache = rows
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def text_display_rows(width)
|
|
72
|
+
@text.split(/\r\n|\r|\n/, -1).flat_map do |line|
|
|
73
|
+
chunks = ANSI.wrap_visible(line, width)
|
|
74
|
+
chunks.empty? ? [""] : chunks
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def invalidate_display_rows_cache
|
|
79
|
+
@display_rows_cache_width = nil
|
|
80
|
+
@display_rows_cache_banner_count = nil
|
|
81
|
+
@display_rows_cache = nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Terminal transcript rendering helpers.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Renderer for transcript entries in the terminal prompt interface.
|
|
6
|
+
module TranscriptRenderer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def write_stream_block_locked(label, delta, finish: false)
|
|
10
|
+
with_synchronized_output_locked do
|
|
11
|
+
prepare_transcript_output_locked unless @restoring_transcript
|
|
12
|
+
if label && @stream_state.block != label
|
|
13
|
+
ensure_transcript_block_separator_locked
|
|
14
|
+
write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
|
|
15
|
+
@stream_state.start_block(label)
|
|
16
|
+
end
|
|
17
|
+
write_transcript_text_locked(delta) unless delta.empty?
|
|
18
|
+
write_transcript_text_locked("\n") if finish && @stream_state.block
|
|
19
|
+
@stream_state.finish_block if finish
|
|
20
|
+
restore_composer_cursor_locked unless @restoring_transcript
|
|
21
|
+
end
|
|
22
|
+
@output_io.flush unless @restoring_transcript
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write_transcript_text_locked(text)
|
|
26
|
+
append_transcript_buffer(text.to_s)
|
|
27
|
+
remember_transcript_viewport_locked unless text.to_s.empty?
|
|
28
|
+
write_visual_transcript_text_locked(text)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_visual_transcript_text_locked(text)
|
|
32
|
+
width, height = screen_size
|
|
33
|
+
output_text = terminal_newlines(text.to_s)
|
|
34
|
+
advance_pending_stream_wrap_locked(output_text, width: width, height: height)
|
|
35
|
+
@output_io.print(output_text)
|
|
36
|
+
update_stream_position(output_text, width: width)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def append_transcript_buffer(text)
|
|
40
|
+
@transcript_buffer.append(text.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def invalidate_transcript_display_rows_cache
|
|
44
|
+
@transcript_buffer.invalidate_display_rows_cache
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_transcript_block_separator_locked
|
|
48
|
+
return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
|
|
49
|
+
|
|
50
|
+
write_transcript_text_locked(@transcript_buffer.end_with?("\n") ? "\n" : "\n\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def terminal_newlines(text)
|
|
54
|
+
text.gsub(/\r\n|\r|\n/, "\r\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def redraw_transcript_locked(width: screen_width, height: screen_height)
|
|
58
|
+
return unless transcript_renderable?
|
|
59
|
+
|
|
60
|
+
rows = transcript_viewport_rows(transcript_redraw_row_count(height), width)
|
|
61
|
+
clear_screen_rows_locked(1, rows.length)
|
|
62
|
+
return if rows.empty?
|
|
63
|
+
|
|
64
|
+
move_to_screen(1, 1)
|
|
65
|
+
@output_io.print(terminal_newlines(rows.join("\n")))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def transcript_viewport_text(row_count, width)
|
|
69
|
+
transcript_viewport_rows(row_count, width).join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def transcript_viewport_rows(row_count, width)
|
|
73
|
+
return [] unless row_count.positive?
|
|
74
|
+
|
|
75
|
+
rows = transcript_display_rows(width).last(row_count)
|
|
76
|
+
rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
|
|
77
|
+
rows
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def remember_transcript_viewport_locked(height = screen_height)
|
|
81
|
+
@transcript_viewport_rows = transcript_bottom_row(height)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def transcript_renderable?
|
|
85
|
+
@visual_banner_count.positive? || !@transcript_buffer.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def transcript_display_rows(width)
|
|
89
|
+
@transcript_buffer.display_rows(width, visual_banner_count: @visual_banner_count, banner_rows: method(:banner_rows))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def transcript_text_display_rows(width)
|
|
93
|
+
@transcript_buffer.text_display_rows(width)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reset_stream_position_from_transcript_locked(width = screen_width)
|
|
97
|
+
@stream_state.reset_position_from_rows(transcript_display_rows(width), width)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def move_to_transcript_cursor_locked(width: screen_width, height: screen_height)
|
|
101
|
+
if @stream_state.pending_wrap?
|
|
102
|
+
move_to_screen(transcript_bottom_row(height), width)
|
|
103
|
+
else
|
|
104
|
+
move_to_screen(transcript_bottom_row(height), [@stream_state.col + 1, width].min)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def advance_pending_stream_wrap_locked(output_text, width: screen_width, height: screen_height)
|
|
109
|
+
return unless @stream_state.pending_wrap?
|
|
110
|
+
return if output_text.empty? || output_text.start_with?("\r", "\n")
|
|
111
|
+
|
|
112
|
+
move_to_screen(transcript_bottom_row(height), width)
|
|
113
|
+
@output_io.print("\r\n")
|
|
114
|
+
@stream_state.clear_pending_wrap
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def update_stream_position(text, width: screen_width)
|
|
118
|
+
@stream_state.update_position(text, width: width)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def transcript_label(label)
|
|
122
|
+
case label
|
|
123
|
+
when "Assistant"
|
|
124
|
+
@assistant_label
|
|
125
|
+
when "Tool failed"
|
|
126
|
+
"Tool"
|
|
127
|
+
else
|
|
128
|
+
label
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def label_styles(label)
|
|
133
|
+
case label
|
|
134
|
+
when "Reasoning", "Compaction summary"
|
|
135
|
+
[:gray, :bold]
|
|
136
|
+
when "Assistant", "Kward"
|
|
137
|
+
[:green, :bold]
|
|
138
|
+
when "Tool", "Tool output"
|
|
139
|
+
[:cyan, :bold]
|
|
140
|
+
when "Tool failed"
|
|
141
|
+
[:red, :bold]
|
|
142
|
+
when "Retry"
|
|
143
|
+
[:yellow, :bold]
|
|
144
|
+
else
|
|
145
|
+
[:gray, :bold]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|