kward 0.71.0 → 0.72.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/CHANGELOG.md +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +53 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Lightweight syntax-based auto-indent for the built-in composer file editor.
|
|
6
|
+
module EditorAutoIndent
|
|
7
|
+
C_LIKE_INDENT_LANGUAGES = %i[javascript typescript json css scss go rust java csharp c cpp swift kotlin].freeze
|
|
8
|
+
PUNCTUATION_INDENT_LANGUAGES = (C_LIKE_INDENT_LANGUAGES + %i[ruby python shell lua html]).freeze
|
|
9
|
+
RUBY_INDENT_KEYWORDS = %w[begin case class def do else elsif ensure for if module rescue unless until while].freeze
|
|
10
|
+
SHELL_INDENT_KEYWORDS = %w[case do else elif if select then until while].freeze
|
|
11
|
+
PYTHON_INDENT_KEYWORDS = %w[class def elif else except finally for if try while with].freeze
|
|
12
|
+
LUA_INDENT_KEYWORDS = %w[do else elseif for function if repeat then while].freeze
|
|
13
|
+
SHELL_DEDENT_KEYWORDS = %w[fi done esac].freeze
|
|
14
|
+
PUNCTUATION_PAIRS = { "}" => "{", "]" => "[", ")" => "(" }.freeze
|
|
15
|
+
EDITOR_TAB_SEQUENCES = ["\t", "\e[9u", "\e[9;1u", "\e[27;1;9~"].freeze
|
|
16
|
+
EDITOR_SHIFT_TAB_SEQUENCES = ["\e[Z", "\e[1;2Z", "\e[9;2u", "\e[27;2;9~"].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def editor_insert_newline
|
|
21
|
+
return @editor_state.insert("\n") unless current_editor_auto_indent?
|
|
22
|
+
return true if editor_insert_endwise_newline
|
|
23
|
+
|
|
24
|
+
block_indent = editor_multiline_block_indent
|
|
25
|
+
if block_indent
|
|
26
|
+
inner_indent, closing_indent = block_indent
|
|
27
|
+
@editor_state.insert("\n#{inner_indent}\n#{closing_indent}")
|
|
28
|
+
@editor_state.cursor -= closing_indent.length + 1
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@editor_state.insert("\n#{editor_newline_indent}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def editor_insert_printable(text)
|
|
36
|
+
text = text.to_s
|
|
37
|
+
return if editor_insert_printable_with_pairs(text)
|
|
38
|
+
|
|
39
|
+
clear_editor_selection_before_edit
|
|
40
|
+
return @editor_state.insert(text) unless current_editor_auto_indent?
|
|
41
|
+
return @editor_state.insert(text) unless text.length == 1
|
|
42
|
+
|
|
43
|
+
editor_reindent_for_closing_punctuation(text) if editor_closing_punctuation?(text)
|
|
44
|
+
@editor_state.insert(text)
|
|
45
|
+
editor_reindent_for_completed_word_closer
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def editor_delete_before_cursor
|
|
49
|
+
return true if editor_delete_auto_close_pair_before_cursor
|
|
50
|
+
return @editor_state.delete_before_cursor unless current_editor_auto_indent?
|
|
51
|
+
return @editor_state.delete_before_cursor unless editor_cursor_in_leading_indent?
|
|
52
|
+
|
|
53
|
+
unit = editor_indent_unit
|
|
54
|
+
return @editor_state.delete_before_cursor if unit.empty?
|
|
55
|
+
return @editor_state.delete_before_cursor unless @editor_state.cursor >= unit.length
|
|
56
|
+
return @editor_state.delete_before_cursor unless @editor_state.buffer[(@editor_state.cursor - unit.length)...@editor_state.cursor] == unit
|
|
57
|
+
|
|
58
|
+
@editor_state.replace_range(@editor_state.cursor - unit.length, @editor_state.cursor, "")
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_editor_tab_key(key)
|
|
63
|
+
tab_sequence = editor_tab_sequence_for(key)
|
|
64
|
+
if tab_sequence
|
|
65
|
+
queue_editor_tab_remaining(key, tab_sequence)
|
|
66
|
+
if !editor_search_active?
|
|
67
|
+
block_given? ? yield(:forward) : editor_insert_tab
|
|
68
|
+
end
|
|
69
|
+
return true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
shift_tab_sequence = editor_shift_tab_sequence_for(key)
|
|
73
|
+
if shift_tab_sequence
|
|
74
|
+
queue_editor_tab_remaining(key, shift_tab_sequence)
|
|
75
|
+
if !editor_search_active?
|
|
76
|
+
block_given? ? yield(:backward) : editor_outdent_tab
|
|
77
|
+
end
|
|
78
|
+
return true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def editor_insert_tab
|
|
85
|
+
if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
|
|
86
|
+
return @editor_state.replace_selections(editor_indent_unit)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if current_editor_auto_indent? && editor_cursor_in_leading_indent?
|
|
90
|
+
return editor_smart_tab_forward
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@editor_state.insert(editor_tab_padding)
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def editor_outdent_tab
|
|
98
|
+
return true if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
|
|
99
|
+
|
|
100
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
101
|
+
line = @editor_state.lines[line_index].to_s
|
|
102
|
+
old_indent = line[/\A[ \t]*/].to_s
|
|
103
|
+
|
|
104
|
+
return true if old_indent.empty?
|
|
105
|
+
|
|
106
|
+
reference_column = column <= old_indent.length ? column : old_indent.length
|
|
107
|
+
reference_column = old_indent.length if reference_column.zero?
|
|
108
|
+
target_width = previous_indent_stop(reference_column)
|
|
109
|
+
editor_update_current_line_indent(editor_indent_for_width(target_width), preserve_content_column: column > old_indent.length)
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def editor_tab_sequence_for(key)
|
|
114
|
+
return nil unless key.is_a?(String)
|
|
115
|
+
|
|
116
|
+
EDITOR_TAB_SEQUENCES.find { |sequence| key.start_with?(sequence) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def editor_shift_tab_sequence_for(key)
|
|
120
|
+
return nil unless key.is_a?(String)
|
|
121
|
+
|
|
122
|
+
EDITOR_SHIFT_TAB_SEQUENCES.find { |sequence| key.start_with?(sequence) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def queue_editor_tab_remaining(key, sequence)
|
|
126
|
+
return unless key.length > sequence.length
|
|
127
|
+
return if sequence.end_with?("u")
|
|
128
|
+
|
|
129
|
+
queue_pending_keys(key[sequence.length..])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def current_editor_auto_indent?
|
|
133
|
+
return @editor_auto_indent_source.call != false if @editor_auto_indent_source.respond_to?(:call)
|
|
134
|
+
|
|
135
|
+
@editor_auto_indent != false
|
|
136
|
+
rescue StandardError
|
|
137
|
+
@editor_auto_indent != false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def editor_newline_indent
|
|
141
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
142
|
+
line = @editor_state.lines[line_index].to_s
|
|
143
|
+
before_cursor = line[0...column].to_s
|
|
144
|
+
base_indent = line[/\A[ \t]*/].to_s
|
|
145
|
+
language = editor_syntax_language
|
|
146
|
+
indent = base_indent.dup
|
|
147
|
+
indent += editor_indent_unit if editor_line_opens_indent?(before_cursor, language)
|
|
148
|
+
indent
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def editor_smart_tab_forward
|
|
152
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
153
|
+
line = @editor_state.lines[line_index].to_s
|
|
154
|
+
old_indent = line[/\A[ \t]*/].to_s
|
|
155
|
+
target_indent = editor_expected_indent_for_line(line_index)
|
|
156
|
+
expected_width = indent_width(target_indent)
|
|
157
|
+
target_width = column < expected_width ? expected_width : next_indent_stop(column)
|
|
158
|
+
|
|
159
|
+
if indent_width(old_indent) >= target_width
|
|
160
|
+
@editor_state.cursor = @editor_state.line_start_offset(line_index) + target_width
|
|
161
|
+
else
|
|
162
|
+
editor_update_current_line_indent(editor_indent_for_width(target_width), preserve_content_column: column > old_indent.length)
|
|
163
|
+
end
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def editor_tab_padding
|
|
168
|
+
unit = editor_indent_unit
|
|
169
|
+
return unit if unit == "\t"
|
|
170
|
+
|
|
171
|
+
width = indent_width(unit)
|
|
172
|
+
width = 2 unless width.positive?
|
|
173
|
+
column = @editor_state.cursor_line_and_column[1]
|
|
174
|
+
" " * (width - (column % width))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def editor_expected_indent_for_line(line_index)
|
|
178
|
+
line = @editor_state.lines[line_index].to_s
|
|
179
|
+
code = editor_indent_code(line, editor_syntax_language).strip
|
|
180
|
+
matching_indent = editor_matching_indent_for_line(code)
|
|
181
|
+
return matching_indent if matching_indent
|
|
182
|
+
|
|
183
|
+
previous_line = previous_non_blank_editor_line(line_index)
|
|
184
|
+
return "" unless previous_line
|
|
185
|
+
|
|
186
|
+
indent = previous_line[:indent].dup
|
|
187
|
+
indent += editor_indent_unit if editor_line_opens_indent?(previous_line[:code], editor_syntax_language)
|
|
188
|
+
indent
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def editor_matching_indent_for_line(code)
|
|
192
|
+
return nil if code.empty?
|
|
193
|
+
return editor_matching_punctuation_indent(code[0]) if editor_closing_punctuation?(code[0])
|
|
194
|
+
|
|
195
|
+
editor_matching_word_indent if editor_completed_word_closer?(code, editor_syntax_language)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def previous_non_blank_editor_line(line_index)
|
|
199
|
+
(line_index.to_i - 1).downto(0) do |index|
|
|
200
|
+
line = @editor_state.lines[index].to_s
|
|
201
|
+
code = editor_indent_code(line, editor_syntax_language).rstrip
|
|
202
|
+
next if code.strip.empty?
|
|
203
|
+
|
|
204
|
+
return { indent: line[/\A[ \t]*/].to_s, code: code }
|
|
205
|
+
end
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def editor_update_current_line_indent(indent, preserve_content_column: false)
|
|
210
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
211
|
+
line_start = @editor_state.line_start_offset(line_index)
|
|
212
|
+
line = @editor_state.lines[line_index].to_s
|
|
213
|
+
old_indent = line[/\A[ \t]*/].to_s
|
|
214
|
+
content_column = preserve_content_column ? [column - old_indent.length, 0].max : 0
|
|
215
|
+
@editor_state.replace_range(line_start, line_start + old_indent.length, indent.to_s)
|
|
216
|
+
@editor_state.cursor = line_start + indent.to_s.length + content_column
|
|
217
|
+
true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def next_indent_stop(column)
|
|
221
|
+
width = indent_width(editor_indent_unit)
|
|
222
|
+
width = 2 unless width.positive?
|
|
223
|
+
column + width - (column % width)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def previous_indent_stop(column)
|
|
227
|
+
width = indent_width(editor_indent_unit)
|
|
228
|
+
width = 2 unless width.positive?
|
|
229
|
+
[column - 1, 0].max / width * width
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def editor_indent_for_width(width)
|
|
233
|
+
unit = editor_indent_unit
|
|
234
|
+
return "\t" * width.to_i if unit == "\t"
|
|
235
|
+
|
|
236
|
+
" " * [width.to_i, 0].max
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def indent_width(text)
|
|
240
|
+
text.to_s.each_char.sum { |char| char == "\t" ? 1 : 1 }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def editor_multiline_block_indent
|
|
244
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
245
|
+
line = @editor_state.lines[line_index].to_s
|
|
246
|
+
before_cursor = line[0...column].to_s
|
|
247
|
+
base_indent = line[/\A[ \t]*/].to_s
|
|
248
|
+
language = editor_syntax_language
|
|
249
|
+
opens_indent = editor_line_opens_indent?(before_cursor, language)
|
|
250
|
+
paired_closer = editor_next_paired_closer
|
|
251
|
+
return nil unless paired_closer
|
|
252
|
+
return nil unless opens_indent || editor_auto_close_pair_opener_before_cursor?
|
|
253
|
+
|
|
254
|
+
[base_indent + editor_indent_unit, base_indent]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def editor_next_paired_closer
|
|
258
|
+
opener = editor_previous_character
|
|
259
|
+
closer = editor_next_character
|
|
260
|
+
return nil unless opener && closer
|
|
261
|
+
return nil unless PromptInterface::EditorAutoClosePairs::AUTO_CLOSE_PAIRS[opener] == closer
|
|
262
|
+
|
|
263
|
+
closer
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def editor_auto_close_pair_opener_before_cursor?
|
|
267
|
+
PromptInterface::EditorAutoClosePairs::AUTO_CLOSE_PAIRS.key?(editor_previous_character)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def editor_indent_unit
|
|
271
|
+
@editor_indent_unit_path ||= nil
|
|
272
|
+
if @editor_indent_unit_path != @editor_state.path
|
|
273
|
+
@editor_indent_unit_path = @editor_state.path
|
|
274
|
+
@editor_indent_unit = detect_editor_indent_unit
|
|
275
|
+
end
|
|
276
|
+
@editor_indent_unit
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def detect_editor_indent_unit
|
|
280
|
+
indents = @editor_state.lines.filter_map do |line|
|
|
281
|
+
whitespace = line[/\A[ \t]+(?=\S)/].to_s
|
|
282
|
+
whitespace.empty? ? nil : whitespace
|
|
283
|
+
end
|
|
284
|
+
return " " if indents.empty?
|
|
285
|
+
|
|
286
|
+
tab_count = indents.sum { |indent| indent.count("\t") }
|
|
287
|
+
space_count = indents.sum { |indent| indent.count(" ") }
|
|
288
|
+
return "\t" if tab_count > space_count
|
|
289
|
+
|
|
290
|
+
widths = indents.filter_map do |indent|
|
|
291
|
+
next if indent.include?("\t")
|
|
292
|
+
|
|
293
|
+
indent.length if indent.length.positive?
|
|
294
|
+
end
|
|
295
|
+
positive_deltas = widths.each_cons(2).filter_map do |previous, current|
|
|
296
|
+
delta = current - previous
|
|
297
|
+
delta.positive? ? delta : nil
|
|
298
|
+
end
|
|
299
|
+
candidates = positive_deltas.empty? ? widths : positive_deltas
|
|
300
|
+
detected = candidates.tally.max_by { |width, count| [count, -width] }&.first
|
|
301
|
+
detected && detected.positive? ? " " * detected : " "
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def editor_line_opens_indent?(line, language)
|
|
305
|
+
return false unless language
|
|
306
|
+
|
|
307
|
+
code = editor_indent_code(line, language).rstrip
|
|
308
|
+
return false if code.empty?
|
|
309
|
+
|
|
310
|
+
return true if code.end_with?("{", "[", "(")
|
|
311
|
+
|
|
312
|
+
case language
|
|
313
|
+
when :ruby
|
|
314
|
+
editor_ruby_line_opens_indent?(code)
|
|
315
|
+
when :shell
|
|
316
|
+
editor_keyword_line_opens_indent?(code, SHELL_INDENT_KEYWORDS)
|
|
317
|
+
when :python
|
|
318
|
+
code.end_with?(":") || editor_keyword_line_opens_indent?(code, PYTHON_INDENT_KEYWORDS)
|
|
319
|
+
when :lua
|
|
320
|
+
editor_keyword_line_opens_indent?(code, LUA_INDENT_KEYWORDS)
|
|
321
|
+
when :yaml
|
|
322
|
+
code.match?(/:\s*(?:[#].*)?\z/)
|
|
323
|
+
when :html
|
|
324
|
+
editor_html_line_opens_indent?(code)
|
|
325
|
+
when *C_LIKE_INDENT_LANGUAGES
|
|
326
|
+
false
|
|
327
|
+
else
|
|
328
|
+
false
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def editor_indent_code(line, language)
|
|
333
|
+
text = line.to_s
|
|
334
|
+
marker = case language
|
|
335
|
+
when :ruby, :python, :shell, :yaml
|
|
336
|
+
"#"
|
|
337
|
+
when :lua, :sql
|
|
338
|
+
"--"
|
|
339
|
+
else
|
|
340
|
+
"//"
|
|
341
|
+
end
|
|
342
|
+
index = editor_comment_index(text, marker)
|
|
343
|
+
index ? text[0...index].to_s : text
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def editor_ruby_line_opens_indent?(code)
|
|
347
|
+
return false if code.match?(/\b(?:end|else|elsif|ensure|rescue)\b\z/)
|
|
348
|
+
return true if code.match?(/\A\s*(?:#{Regexp.union(RUBY_INDENT_KEYWORDS)})\b/)
|
|
349
|
+
|
|
350
|
+
code.match?(/\bdo(?:\s*\|[^|]*\|)?\s*\z/)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def editor_keyword_line_opens_indent?(code, keywords)
|
|
354
|
+
code.match?(/\b(?:#{Regexp.union(keywords)})\b\s*\z/)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def editor_html_line_opens_indent?(code)
|
|
358
|
+
tag = code.match(/<([A-Za-z][\w:-]*)(?:\s[^>]*)?>\s*\z/)
|
|
359
|
+
return false unless tag
|
|
360
|
+
return false if code.match?(/<\/[^>]+>\s*\z/)
|
|
361
|
+
return false if code.match?(/\/>\s*\z/)
|
|
362
|
+
|
|
363
|
+
true
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def editor_closing_punctuation?(text)
|
|
367
|
+
PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def editor_reindent_for_closing_punctuation(text)
|
|
371
|
+
return unless editor_cursor_in_leading_indent?
|
|
372
|
+
|
|
373
|
+
indent = editor_matching_punctuation_indent(text)
|
|
374
|
+
editor_reindent_current_line(indent) if indent
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def editor_reindent_for_completed_word_closer
|
|
378
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
379
|
+
line = @editor_state.lines[line_index].to_s
|
|
380
|
+
before_cursor = line[0...column].to_s
|
|
381
|
+
return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
|
|
382
|
+
|
|
383
|
+
indent = editor_matching_word_indent
|
|
384
|
+
editor_reindent_current_line(indent) if indent
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def editor_cursor_in_leading_indent?
|
|
388
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
389
|
+
line = @editor_state.lines[line_index].to_s
|
|
390
|
+
column <= line[/\A[ \t]*/].to_s.length
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def editor_reindent_current_line(indent)
|
|
394
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
395
|
+
line_start = @editor_state.line_start_offset(line_index)
|
|
396
|
+
line = @editor_state.lines[line_index].to_s
|
|
397
|
+
old_indent = line[/\A[ \t]*/].to_s
|
|
398
|
+
new_indent = indent.to_s
|
|
399
|
+
return false if old_indent == new_indent
|
|
400
|
+
|
|
401
|
+
content_column = [column - old_indent.length, 0].max
|
|
402
|
+
@editor_state.replace_range(line_start, line_start + old_indent.length, new_indent)
|
|
403
|
+
@editor_state.cursor = line_start + new_indent.length + content_column
|
|
404
|
+
true
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def editor_completed_word_closer?(text, language)
|
|
408
|
+
code = editor_indent_code(text, language).rstrip
|
|
409
|
+
case language
|
|
410
|
+
when :ruby
|
|
411
|
+
code.match?(/\A[ \t]*end\z/)
|
|
412
|
+
when :lua
|
|
413
|
+
code.match?(/\A[ \t]*(?:end|until)\z/)
|
|
414
|
+
when :shell
|
|
415
|
+
code.match?(/\A[ \t]*(?:#{Regexp.union(SHELL_DEDENT_KEYWORDS)})\z/)
|
|
416
|
+
when :html
|
|
417
|
+
code.match?(/\A[ \t]*<\/[A-Za-z][\w:-]*>\z/)
|
|
418
|
+
else
|
|
419
|
+
false
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def editor_matching_word_indent
|
|
424
|
+
case editor_syntax_language
|
|
425
|
+
when :ruby
|
|
426
|
+
editor_matching_keyword_indent("end", %w[end])
|
|
427
|
+
when :lua
|
|
428
|
+
editor_matching_keyword_indent("end", %w[end until])
|
|
429
|
+
when :shell
|
|
430
|
+
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS)
|
|
431
|
+
when :html
|
|
432
|
+
editor_matching_html_indent
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def editor_matching_punctuation_indent(text)
|
|
437
|
+
opener = PUNCTUATION_PAIRS[text]
|
|
438
|
+
stack = []
|
|
439
|
+
editor_previous_code_lines.each do |line|
|
|
440
|
+
editor_scan_punctuation_tokens(line[:code]).each do |token|
|
|
441
|
+
if token == opener
|
|
442
|
+
stack << line[:indent]
|
|
443
|
+
elsif token == text
|
|
444
|
+
stack.pop
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
stack.last || ""
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def editor_matching_keyword_indent(opener = nil, closers = [])
|
|
452
|
+
stack = []
|
|
453
|
+
editor_previous_code_lines.each do |line|
|
|
454
|
+
code = line[:code].strip
|
|
455
|
+
next if code.empty?
|
|
456
|
+
|
|
457
|
+
stack.pop if editor_word_closer_line?(code, closers)
|
|
458
|
+
stack << line[:indent] if editor_word_opener_line?(code, opener)
|
|
459
|
+
end
|
|
460
|
+
stack.last || ""
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def editor_matching_html_indent
|
|
464
|
+
stack = []
|
|
465
|
+
editor_previous_code_lines.each do |line|
|
|
466
|
+
code = line[:code].strip
|
|
467
|
+
next if code.empty?
|
|
468
|
+
|
|
469
|
+
stack.pop if code.match?(/\A<\/[A-Za-z][\w:-]*>/)
|
|
470
|
+
stack << line[:indent] if editor_html_line_opens_indent?(code)
|
|
471
|
+
end
|
|
472
|
+
stack.last || ""
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def editor_previous_code_lines
|
|
476
|
+
line_index, = @editor_state.cursor_line_and_column
|
|
477
|
+
@editor_state.lines.first(line_index).filter_map do |line|
|
|
478
|
+
code = editor_indent_code(line, editor_syntax_language).rstrip
|
|
479
|
+
next if code.strip.empty?
|
|
480
|
+
|
|
481
|
+
{ indent: line[/\A[ \t]*/].to_s, code: code }
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def editor_scan_punctuation_tokens(code)
|
|
486
|
+
code.to_s.scan(/[{}\[\]()]/)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def editor_word_opener_line?(code, opener)
|
|
490
|
+
case editor_syntax_language
|
|
491
|
+
when :ruby
|
|
492
|
+
editor_ruby_line_opens_indent?(code)
|
|
493
|
+
when :lua
|
|
494
|
+
editor_keyword_line_opens_indent?(code, LUA_INDENT_KEYWORDS)
|
|
495
|
+
when :shell
|
|
496
|
+
editor_keyword_line_opens_indent?(code, SHELL_INDENT_KEYWORDS)
|
|
497
|
+
else
|
|
498
|
+
opener && code.match?(/\b#{Regexp.escape(opener)}\b/)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def editor_word_closer_line?(code, closers)
|
|
503
|
+
return false if closers.empty?
|
|
504
|
+
|
|
505
|
+
code.match?(/\A(?:#{Regexp.union(closers)})\b/)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Text storage and line/offset mechanics for editor buffers.
|
|
6
|
+
class EditorBuffer
|
|
7
|
+
attr_reader :text
|
|
8
|
+
|
|
9
|
+
def initialize(text = "")
|
|
10
|
+
@text = text.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def text=(value)
|
|
14
|
+
@text = value.to_s
|
|
15
|
+
invalidate_lines_cache
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def length
|
|
19
|
+
@text.length
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def empty?
|
|
23
|
+
@text.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](range)
|
|
27
|
+
@text[range]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def slice(start_index, length = nil)
|
|
31
|
+
length.nil? ? @text[start_index] : @text[start_index, length]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def before(offset)
|
|
35
|
+
@text[0...offset].to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def after(offset)
|
|
39
|
+
@text[offset..].to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def lines
|
|
43
|
+
@lines_cache ||= begin
|
|
44
|
+
values = @text.split("\n", -1)
|
|
45
|
+
values.empty? ? [""] : values
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def line_and_column_for(offset)
|
|
50
|
+
before_cursor = before(offset)
|
|
51
|
+
[before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def offset_for_line_and_column(line_index, column)
|
|
55
|
+
values = lines
|
|
56
|
+
line_index = [[line_index.to_i, 0].max, values.length - 1].min
|
|
57
|
+
column = [[column.to_i, 0].max, values[line_index].length].min
|
|
58
|
+
values.first(line_index).sum { |line| line.length + 1 } + column
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def line_start_offset(line_index)
|
|
62
|
+
line_index = [[line_index.to_i, 0].max, lines.length - 1].min
|
|
63
|
+
lines.first(line_index).sum { |line| line.length + 1 }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def line_range(line_index)
|
|
67
|
+
start_index = line_start_offset(line_index)
|
|
68
|
+
end_index = start_index + lines[line_index].to_s.length
|
|
69
|
+
end_index += 1 if end_index < @text.length
|
|
70
|
+
[start_index, end_index]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def replace_range(start_index, end_index, text)
|
|
74
|
+
start_index, end_index = [start_index, end_index].minmax
|
|
75
|
+
start_index = [[start_index, 0].max, @text.length].min
|
|
76
|
+
end_index = [[end_index, 0].max, @text.length].min
|
|
77
|
+
@text = @text[0...start_index].to_s + text.to_s + @text[end_index..].to_s
|
|
78
|
+
invalidate_lines_cache
|
|
79
|
+
[start_index, start_index + text.to_s.length]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def insert(offset, text)
|
|
83
|
+
replace_range(offset, offset, text)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def delete_range(start_index, end_index)
|
|
87
|
+
replace_range(start_index, end_index, "")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def index(*arguments)
|
|
91
|
+
@text.index(*arguments)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def rindex(*arguments)
|
|
95
|
+
@text.rindex(*arguments)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def count(*arguments)
|
|
99
|
+
@text.count(*arguments)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def invalidate_lines_cache
|
|
105
|
+
@lines_cache = nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|