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,262 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# High-level composer input loop and submission controller.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Text composer state transitions for keyboard input, paste, history, and submission.
|
|
6
|
+
module ComposerController
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def composer_input
|
|
10
|
+
@composer.input
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def composer_input=(value)
|
|
14
|
+
@composer.input = value.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def composer_cursor
|
|
18
|
+
@composer.cursor
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def composer_cursor=(value)
|
|
22
|
+
@composer.cursor = value.to_i
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def composer_attachments
|
|
26
|
+
@composer.attachments
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def composer_kill_buffer
|
|
30
|
+
@composer.kill_buffer
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def composer_kill_buffer=(value)
|
|
34
|
+
@composer.kill_buffer = value.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def insert_key(key)
|
|
38
|
+
return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
|
|
39
|
+
|
|
40
|
+
insert_string(key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def insert_string(string)
|
|
44
|
+
return if string.empty?
|
|
45
|
+
|
|
46
|
+
reset_slash_selection
|
|
47
|
+
reset_history_navigation
|
|
48
|
+
@slash_overlay_dismissed_input = nil
|
|
49
|
+
@composer.insert_string(string)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def insert_paste(string)
|
|
53
|
+
parsed = parse_attachments(string)
|
|
54
|
+
Array(parsed[:attachments]).each { |attachment| add_attachment(attachment) }
|
|
55
|
+
insert_string(parsed[:text].to_s) unless parsed[:text].to_s.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_attachments(string)
|
|
59
|
+
return { text: string.to_s, attachments: [] } unless @attachment_parser
|
|
60
|
+
|
|
61
|
+
result = @attachment_parser.call(string.to_s)
|
|
62
|
+
return { text: string.to_s, attachments: [] } unless result.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
text: result[:text] || result["text"] || "",
|
|
66
|
+
attachments: result[:attachments] || result["attachments"] || []
|
|
67
|
+
}
|
|
68
|
+
rescue StandardError
|
|
69
|
+
{ text: string.to_s, attachments: [] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_attachment(attachment)
|
|
73
|
+
@composer.add_attachment(attachment)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_before_cursor
|
|
77
|
+
if @composer.cursor.zero?
|
|
78
|
+
remove_last_attachment
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
reset_slash_selection
|
|
83
|
+
reset_history_navigation
|
|
84
|
+
@composer.delete_before_cursor
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def remove_last_attachment
|
|
88
|
+
return unless @composer.remove_last_attachment
|
|
89
|
+
|
|
90
|
+
reset_slash_selection
|
|
91
|
+
reset_history_navigation
|
|
92
|
+
@slash_overlay_dismissed_input = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delete_at_cursor
|
|
96
|
+
return unless @composer.cursor < @composer.input.length
|
|
97
|
+
|
|
98
|
+
reset_slash_selection
|
|
99
|
+
reset_history_navigation
|
|
100
|
+
@slash_overlay_dismissed_input = nil
|
|
101
|
+
@composer.delete_at_cursor
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def move_cursor_left
|
|
105
|
+
@composer.move_cursor_left
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def move_cursor_right
|
|
109
|
+
@composer.move_cursor_right
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def move_to_start_of_line
|
|
113
|
+
@composer.move_to_start_of_line
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def move_to_end_of_line
|
|
117
|
+
@composer.move_to_end_of_line
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def move_to_previous_word
|
|
121
|
+
@composer.move_to_previous_word
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def move_to_next_word
|
|
125
|
+
@composer.move_to_next_word
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def delete_at_cursor_or_exit
|
|
129
|
+
composer_input.empty? ? exit_input : delete_at_cursor
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def delete_word_before_cursor
|
|
133
|
+
reset_slash_selection
|
|
134
|
+
reset_history_navigation
|
|
135
|
+
@composer.delete_word_before_cursor
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def delete_word_after_cursor
|
|
139
|
+
reset_slash_selection
|
|
140
|
+
reset_history_navigation
|
|
141
|
+
@composer.delete_word_after_cursor
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def kill_line_before_cursor
|
|
145
|
+
reset_slash_selection
|
|
146
|
+
reset_history_navigation
|
|
147
|
+
@composer.kill_line_before_cursor
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def kill_line_after_cursor
|
|
151
|
+
reset_slash_selection
|
|
152
|
+
reset_history_navigation
|
|
153
|
+
@composer.kill_line_after_cursor
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def kill_range(start_index, end_index)
|
|
157
|
+
return unless @composer.kill_range(start_index, end_index)
|
|
158
|
+
|
|
159
|
+
reset_slash_selection
|
|
160
|
+
reset_history_navigation
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def yank_kill_buffer
|
|
164
|
+
@composer.yank_kill_buffer
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def previous_word_boundary(index)
|
|
168
|
+
@composer.previous_word_boundary(index)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def next_word_boundary(index)
|
|
172
|
+
@composer.next_word_boundary(index)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def word_separator?(char)
|
|
176
|
+
@composer.word_separator?(char)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def add_history(value)
|
|
180
|
+
@composer.add_history(value)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def recall_previous_history
|
|
184
|
+
@composer.recall_previous_history
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def recall_next_history
|
|
188
|
+
@composer.recall_next_history
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def replace_input(value)
|
|
192
|
+
@composer.replace_input(value)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def prefill_input(value)
|
|
196
|
+
@mutex.synchronize do
|
|
197
|
+
@composer.prefill_input = value.to_s
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def reset_history_navigation
|
|
202
|
+
@composer.reset_history_navigation
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def submit_input
|
|
207
|
+
value = submitted_input
|
|
208
|
+
add_history(composer_input)
|
|
209
|
+
if @busy
|
|
210
|
+
clear_prompt_for_output_locked
|
|
211
|
+
self.composer_input = ""
|
|
212
|
+
self.composer_cursor = 0
|
|
213
|
+
@composer.clear_attachments
|
|
214
|
+
reset_history_navigation
|
|
215
|
+
@asking = true
|
|
216
|
+
render_prompt_after_output_locked
|
|
217
|
+
else
|
|
218
|
+
clear_prompt_locked
|
|
219
|
+
self.composer_input = ""
|
|
220
|
+
self.composer_cursor = 0
|
|
221
|
+
@composer.clear_attachments
|
|
222
|
+
@asking = false
|
|
223
|
+
@rendered_rows = 0
|
|
224
|
+
@cursor_rendered_row = 0
|
|
225
|
+
end
|
|
226
|
+
@output_io.flush
|
|
227
|
+
value
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def submitted_input
|
|
231
|
+
return composer_input if composer_attachments.empty?
|
|
232
|
+
|
|
233
|
+
sources = composer_attachments.map { |attachment| attachment[:source_text].to_s }.reject(&:empty?)
|
|
234
|
+
display_input = composer_input.to_s.rstrip
|
|
235
|
+
full_input = [display_input, *sources].reject { |part| part.to_s.strip.empty? }.join("\n")
|
|
236
|
+
SubmittedInput.new(full_input, display_input: display_input)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def exit_input
|
|
240
|
+
if @busy
|
|
241
|
+
clear_prompt_for_output_locked
|
|
242
|
+
self.composer_input = ""
|
|
243
|
+
self.composer_cursor = 0
|
|
244
|
+
@composer.clear_attachments
|
|
245
|
+
@asking = true
|
|
246
|
+
render_prompt_after_output_locked
|
|
247
|
+
else
|
|
248
|
+
clear_prompt_locked
|
|
249
|
+
self.composer_input = ""
|
|
250
|
+
self.composer_cursor = 0
|
|
251
|
+
@composer.clear_attachments
|
|
252
|
+
@asking = false
|
|
253
|
+
@rendered_rows = 0
|
|
254
|
+
@cursor_rendered_row = 0
|
|
255
|
+
end
|
|
256
|
+
@output_io.flush
|
|
257
|
+
EXIT_INPUT
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Renderer for the editable composer text area.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Renderer for the editable prompt composer area.
|
|
6
|
+
module ComposerRenderer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def composer_layout(width, height = screen_height)
|
|
10
|
+
return compact_composer_layout(width) if height < 4
|
|
11
|
+
return question_composer_layout(width, height) if @question_state
|
|
12
|
+
|
|
13
|
+
content_width = [width - 4, 1].max
|
|
14
|
+
input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
|
|
15
|
+
attachment_rows = attachment_badge_rows(content_width)
|
|
16
|
+
overlay_rows = active_overlay_rows(width, height: height)
|
|
17
|
+
footer_text = footer_text()
|
|
18
|
+
max_input_rows = max_visible_input_rows(attachment_rows.length, overlay_rows.length, footer_text.empty? ? 0 : 1, height: height)
|
|
19
|
+
visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
|
|
20
|
+
visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
|
|
21
|
+
rows = overlay_rows + [top_border(width)]
|
|
22
|
+
rows.concat(attachment_rows)
|
|
23
|
+
rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
|
|
24
|
+
rows << footer_row(content_width, footer_text) unless footer_text.empty?
|
|
25
|
+
rows << bottom_border(width)
|
|
26
|
+
cursor_row = overlay_rows.length + 1 + attachment_rows.length + input_cursor_row - visible_start
|
|
27
|
+
cursor_col = 2 + [input_cursor_col, content_width - 1].min
|
|
28
|
+
[rows, cursor_row, cursor_col]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def compact_composer_layout(width)
|
|
32
|
+
cursor_line, cursor_col = cursor_logical_position
|
|
33
|
+
prefix = "#{@prompt_label} "
|
|
34
|
+
line = input_lines[cursor_line] || ""
|
|
35
|
+
input_width = [width - prefix.length, 1].max
|
|
36
|
+
visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
|
|
37
|
+
visible = line[visible_start, input_width].to_s
|
|
38
|
+
row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
|
|
39
|
+
[[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def input_layout(content_width)
|
|
43
|
+
cursor_line, cursor_col = cursor_logical_position
|
|
44
|
+
rows = []
|
|
45
|
+
cursor_row = 0
|
|
46
|
+
rendered_row_offset = 0
|
|
47
|
+
|
|
48
|
+
input_lines.each_with_index do |line, index|
|
|
49
|
+
prefix = input_prefix(index)
|
|
50
|
+
continuation_prefix = " " * prefix.length
|
|
51
|
+
available = [content_width - prefix.length, 1].max
|
|
52
|
+
chunks = line.scan(/.{1,#{available}}/m)
|
|
53
|
+
chunks = [""] if chunks.empty?
|
|
54
|
+
if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
|
|
55
|
+
chunks << ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if index == cursor_line
|
|
59
|
+
cursor_row = rendered_row_offset + (cursor_col / available)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
chunks.each_with_index do |chunk, chunk_index|
|
|
63
|
+
rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
|
|
64
|
+
end
|
|
65
|
+
rendered_row_offset += chunks.length
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
prefix = input_prefix(cursor_line)
|
|
69
|
+
available = [content_width - prefix.length, 1].max
|
|
70
|
+
cursor_col_in_row = prefix.length + (cursor_col % available)
|
|
71
|
+
[rows, cursor_row, cursor_col_in_row]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def top_border(width)
|
|
75
|
+
title = composer_title
|
|
76
|
+
status = composer_status_text
|
|
77
|
+
if status
|
|
78
|
+
gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
|
|
79
|
+
if gap >= 0
|
|
80
|
+
return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
plain_title = ANSI.strip(title)
|
|
84
|
+
"#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def composer_title
|
|
88
|
+
label = @prompt_label.delete_suffix(">")
|
|
89
|
+
if @busy && @queued_count.positive?
|
|
90
|
+
status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
|
|
91
|
+
elsif @busy && @steered_count.to_i.positive?
|
|
92
|
+
status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
|
|
93
|
+
elsif @busy
|
|
94
|
+
status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
|
|
95
|
+
else
|
|
96
|
+
status_composer_text(label)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def busy_title(text)
|
|
101
|
+
@busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def composer_status_text
|
|
105
|
+
text = @composer_status&.call.to_s
|
|
106
|
+
return nil if text.empty?
|
|
107
|
+
|
|
108
|
+
status_composer_text(text)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def status_composer_text(text)
|
|
112
|
+
" #{text} "
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def bottom_border(width)
|
|
116
|
+
colored("╰#{"─" * [width - 2, 0].max}╯", :primary_green)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def box_content_row(row, content_width)
|
|
120
|
+
"#{colored("│", :primary_green)} #{row[0, content_width].to_s.ljust(content_width)} #{colored("│", :primary_green)}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def footer_row(content_width, text = footer_text)
|
|
124
|
+
return nil if text.empty?
|
|
125
|
+
|
|
126
|
+
box_content_row(visible_truncate(text, content_width), content_width)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def footer_text
|
|
130
|
+
return "" unless @footer
|
|
131
|
+
|
|
132
|
+
@footer.call.to_s.gsub(/\s+/, " ").strip
|
|
133
|
+
rescue StandardError
|
|
134
|
+
""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def attachment_badge_rows(content_width)
|
|
138
|
+
attachment_badge_texts.map { |text| box_content_row(visible_truncate(text, content_width), content_width) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def attachment_badge_texts
|
|
142
|
+
return [] unless @attachment_badges
|
|
143
|
+
|
|
144
|
+
Array(@attachment_badges.call(composer_input, composer_attachments)).map(&:to_s).reject(&:empty?)
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
Array(@attachment_badges.call(composer_input)).map(&:to_s).reject(&:empty?)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def max_visible_input_rows(attachment_count = 0, overlay_count = active_overlay_rows(screen_width).length, footer_count = footer_text.to_s.empty? ? 0 : 1, height: screen_height)
|
|
152
|
+
input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
|
|
153
|
+
[[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def input_lines
|
|
157
|
+
lines = composer_input.split("\n", -1)
|
|
158
|
+
lines.empty? ? [""] : lines
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def input_prefix(_index)
|
|
162
|
+
""
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def cursor_logical_position
|
|
166
|
+
before_cursor = composer_input[0...composer_cursor]
|
|
167
|
+
[before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Mutable text, cursor, history, and overlay state for the composer.
|
|
6
|
+
class ComposerState
|
|
7
|
+
# @return [String] editable text currently shown in the composer
|
|
8
|
+
attr_accessor :input
|
|
9
|
+
# @return [Integer] cursor offset into `input`
|
|
10
|
+
attr_accessor :cursor
|
|
11
|
+
# @return [String] most recently killed text available for yank
|
|
12
|
+
attr_accessor :kill_buffer
|
|
13
|
+
# @return [Integer, nil] active history index while navigating history
|
|
14
|
+
attr_accessor :history_index
|
|
15
|
+
# @return [String, nil] draft restored after leaving history navigation
|
|
16
|
+
attr_accessor :history_draft
|
|
17
|
+
# @return [String, nil] text queued for the next composer prompt
|
|
18
|
+
attr_accessor :prefill_input
|
|
19
|
+
# @return [Array<Hash>] pending image/file attachments submitted with the next turn
|
|
20
|
+
attr_reader :attachments
|
|
21
|
+
# @return [Array<String>] submitted input history
|
|
22
|
+
attr_reader :history
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@input = +""
|
|
26
|
+
@cursor = 0
|
|
27
|
+
@attachments = []
|
|
28
|
+
@kill_buffer = ""
|
|
29
|
+
@history = []
|
|
30
|
+
@history_index = nil
|
|
31
|
+
@history_draft = nil
|
|
32
|
+
@prefill_input = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Removes all pending attachments without changing text input.
|
|
36
|
+
def clear_attachments
|
|
37
|
+
@attachments.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Adds one attachment unless its source is already pending.
|
|
41
|
+
def add_attachment(attachment)
|
|
42
|
+
return false unless attachment.respond_to?(:key?)
|
|
43
|
+
|
|
44
|
+
source = attachment[:source_text] || attachment["source_text"] || attachment[:original_path] || attachment["original_path"]
|
|
45
|
+
return false if source.to_s.empty?
|
|
46
|
+
return false if @attachments.any? { |item| (item[:source_text] || item["source_text"]).to_s == source.to_s }
|
|
47
|
+
|
|
48
|
+
@attachments << attachment
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Removes the most recently added attachment.
|
|
53
|
+
def remove_last_attachment
|
|
54
|
+
return false if @attachments.empty?
|
|
55
|
+
|
|
56
|
+
@attachments.pop
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Inserts text at the cursor and advances by the inserted length.
|
|
61
|
+
def insert_string(string)
|
|
62
|
+
return if string.empty?
|
|
63
|
+
|
|
64
|
+
@input = @input[0...@cursor] + string + @input[@cursor..]
|
|
65
|
+
@cursor += string.length
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deletes one character before the cursor.
|
|
69
|
+
def delete_before_cursor
|
|
70
|
+
return false if @cursor.zero?
|
|
71
|
+
|
|
72
|
+
@input = @input[0...(@cursor - 1)] + @input[@cursor..]
|
|
73
|
+
@cursor -= 1
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deletes one character at the cursor without moving it.
|
|
78
|
+
def delete_at_cursor
|
|
79
|
+
return false unless @cursor < @input.length
|
|
80
|
+
|
|
81
|
+
@input = @input[0...@cursor] + @input[(@cursor + 1)..]
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Moves the cursor one character left when possible.
|
|
86
|
+
def move_cursor_left
|
|
87
|
+
@cursor -= 1 if @cursor.positive?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Moves the cursor one character right when possible.
|
|
91
|
+
def move_cursor_right
|
|
92
|
+
@cursor += 1 if @cursor < @input.length
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Moves the cursor to the beginning of the input buffer.
|
|
96
|
+
def move_to_start_of_line
|
|
97
|
+
@cursor = 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Moves the cursor to the end of the input buffer.
|
|
101
|
+
def move_to_end_of_line
|
|
102
|
+
@cursor = @input.length
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Moves the cursor to the previous word boundary.
|
|
106
|
+
def move_to_previous_word
|
|
107
|
+
@cursor = previous_word_boundary(@cursor)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Moves the cursor to the next word boundary.
|
|
111
|
+
def move_to_next_word
|
|
112
|
+
@cursor = next_word_boundary(@cursor)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Kills the word before the cursor into `kill_buffer`.
|
|
116
|
+
def delete_word_before_cursor
|
|
117
|
+
kill_range(previous_word_boundary(@cursor), @cursor)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Kills the word after the cursor into `kill_buffer`.
|
|
121
|
+
def delete_word_after_cursor
|
|
122
|
+
kill_range(@cursor, next_word_boundary(@cursor))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Kills all text before the cursor into `kill_buffer`.
|
|
126
|
+
def kill_line_before_cursor
|
|
127
|
+
kill_range(0, @cursor)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Kills all text after the cursor into `kill_buffer`.
|
|
131
|
+
def kill_line_after_cursor
|
|
132
|
+
kill_range(@cursor, @input.length)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Removes a range, stores it in `kill_buffer`, and moves the cursor to the start.
|
|
136
|
+
def kill_range(start_index, end_index)
|
|
137
|
+
return false if start_index == end_index
|
|
138
|
+
|
|
139
|
+
@kill_buffer = @input[start_index...end_index].to_s
|
|
140
|
+
@input = @input[0...start_index].to_s + @input[end_index..].to_s
|
|
141
|
+
@cursor = start_index
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Inserts the last killed text at the cursor.
|
|
146
|
+
def yank_kill_buffer
|
|
147
|
+
insert_string(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Finds the start offset of the word before `index`.
|
|
151
|
+
def previous_word_boundary(index)
|
|
152
|
+
cursor = index
|
|
153
|
+
cursor -= 1 while cursor.positive? && word_separator?(@input[cursor - 1])
|
|
154
|
+
cursor -= 1 while cursor.positive? && !word_separator?(@input[cursor - 1])
|
|
155
|
+
cursor
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Finds the end offset of the word after `index`.
|
|
159
|
+
def next_word_boundary(index)
|
|
160
|
+
cursor = index
|
|
161
|
+
cursor += 1 while cursor < @input.length && word_separator?(@input[cursor])
|
|
162
|
+
cursor += 1 while cursor < @input.length && !word_separator?(@input[cursor])
|
|
163
|
+
cursor
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Treats whitespace as the only word separator for composer navigation.
|
|
167
|
+
def word_separator?(char)
|
|
168
|
+
char.to_s.match?(/\s/)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Replaces the full input buffer and places the cursor at the end.
|
|
172
|
+
def replace_input(value)
|
|
173
|
+
@input = value.to_s
|
|
174
|
+
@cursor = @input.length
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns `[row, column]` for cursor placement in multi-line input.
|
|
178
|
+
def cursor_logical_position
|
|
179
|
+
before_cursor = @input[0...@cursor]
|
|
180
|
+
[before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Stores a submitted input unless it is blank or duplicates the previous entry.
|
|
184
|
+
def add_history(value)
|
|
185
|
+
stripped = value.to_s.strip
|
|
186
|
+
return if stripped.empty?
|
|
187
|
+
return if @history.last == value
|
|
188
|
+
|
|
189
|
+
@history << value
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Replaces input with the previous history entry, preserving the draft first.
|
|
193
|
+
def recall_previous_history
|
|
194
|
+
return if @history.empty?
|
|
195
|
+
|
|
196
|
+
@history_draft = @input if @history_index.nil?
|
|
197
|
+
@history_index = @history_index.nil? ? @history.length - 1 : [@history_index - 1, 0].max
|
|
198
|
+
replace_input(@history[@history_index])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Replaces input with the next history entry or restores the saved draft.
|
|
202
|
+
def recall_next_history
|
|
203
|
+
return if @history_index.nil?
|
|
204
|
+
|
|
205
|
+
if @history_index < @history.length - 1
|
|
206
|
+
@history_index += 1
|
|
207
|
+
replace_input(@history[@history_index])
|
|
208
|
+
else
|
|
209
|
+
replace_input(@history_draft || "")
|
|
210
|
+
reset_history_navigation
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Leaves history navigation and clears the saved draft/index state.
|
|
215
|
+
def reset_history_navigation
|
|
216
|
+
@history_index = nil
|
|
217
|
+
@history_draft = nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|