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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. 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