kward 0.71.0 → 0.73.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +288 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -0,0 +1,123 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Auto-close pair behavior for the built-in composer file editor.
6
+ module EditorAutoClosePairs
7
+ AUTO_CLOSE_PAIRS = {
8
+ "(" => ")",
9
+ "[" => "]",
10
+ "{" => "}",
11
+ "\"" => "\"",
12
+ "'" => "'",
13
+ "`" => "`"
14
+ }.freeze
15
+ AUTO_CLOSE_OPENERS = AUTO_CLOSE_PAIRS.keys.freeze
16
+ AUTO_CLOSE_CLOSERS = AUTO_CLOSE_PAIRS.values.uniq.freeze
17
+ AUTO_CLOSE_QUOTES = ["\"", "'", "`"].freeze
18
+ WORD_CHARACTER = /[[:alnum:]_]/.freeze
19
+
20
+ private
21
+
22
+ def editor_insert_printable_with_pairs(text)
23
+ text = text.to_s
24
+ return false unless current_editor_auto_close_pairs?
25
+ return false unless text.length == 1
26
+
27
+ if @editor_state.selection_active? && AUTO_CLOSE_PAIRS.key?(text)
28
+ editor_insert_auto_close_pair(text, AUTO_CLOSE_PAIRS.fetch(text))
29
+ return true
30
+ end
31
+
32
+ if AUTO_CLOSE_CLOSERS.include?(text) && editor_next_character == text
33
+ @editor_state.move_right
34
+ return true
35
+ end
36
+
37
+ if AUTO_CLOSE_PAIRS.key?(text)
38
+ return false if editor_quote_pair?(text) && editor_quote_inside_word?
39
+
40
+ editor_insert_auto_close_pair(text, AUTO_CLOSE_PAIRS.fetch(text))
41
+ return true
42
+ end
43
+
44
+ false
45
+ end
46
+
47
+ def editor_delete_auto_close_pair_before_cursor
48
+ return false unless current_editor_auto_close_pairs?
49
+
50
+ opener = editor_previous_character
51
+ closer = editor_next_character
52
+ return false unless opener && closer
53
+ return false unless AUTO_CLOSE_PAIRS[opener] == closer
54
+
55
+ @editor_state.replace_range(@editor_state.cursor - 1, @editor_state.cursor + 1, "")
56
+ true
57
+ end
58
+
59
+ def current_editor_auto_close_pairs?
60
+ return @editor_auto_close_pairs_source.call != false if @editor_auto_close_pairs_source.respond_to?(:call)
61
+
62
+ @editor_auto_close_pairs != false
63
+ rescue StandardError
64
+ @editor_auto_close_pairs != false
65
+ end
66
+
67
+ def editor_insert_auto_close_pair(opener, closer)
68
+ range = editor_auto_close_pair_range(opener)
69
+ if range
70
+ selected = @editor_state.buffer[range[0]...range[1]].to_s
71
+ @editor_state.replace_range(range[0], range[1], "#{opener}#{selected}#{closer}")
72
+ @editor_state.cursor = range[1] + opener.length + closer.length
73
+ @editor_state.clear_selection
74
+ return
75
+ end
76
+
77
+ @editor_state.insert("#{opener}#{closer}")
78
+ @editor_state.move_left
79
+ end
80
+
81
+ def editor_auto_close_pair_range(opener)
82
+ range = @editor_state.selection_range
83
+ return nil unless range
84
+ return range unless editor_quote_pair?(opener)
85
+ return range if @editor_state.vibe?
86
+
87
+ editor_quote_selection_range(range)
88
+ end
89
+
90
+ def editor_quote_selection_range(range)
91
+ start_index, end_index = range
92
+ return range unless editor_word_character?(@editor_state.buffer[(end_index - 1)...end_index])
93
+ return range unless editor_word_character?(@editor_state.buffer[end_index...(end_index + 1)])
94
+ return range if editor_word_character?(@editor_state.buffer[(start_index - 1)...start_index])
95
+ return range if editor_word_character?(@editor_state.buffer[(end_index + 1)...(end_index + 2)])
96
+
97
+ [start_index, end_index + 1]
98
+ end
99
+
100
+ def editor_quote_pair?(text)
101
+ AUTO_CLOSE_QUOTES.include?(text)
102
+ end
103
+
104
+ def editor_quote_inside_word?
105
+ editor_word_character?(editor_previous_character) || editor_word_character?(editor_next_character)
106
+ end
107
+
108
+ def editor_previous_character
109
+ return nil if @editor_state.cursor.zero?
110
+
111
+ @editor_state.buffer[(@editor_state.cursor - 1)...@editor_state.cursor]
112
+ end
113
+
114
+ def editor_next_character
115
+ @editor_state.buffer[@editor_state.cursor...(@editor_state.cursor + 1)]
116
+ end
117
+
118
+ def editor_word_character?(character)
119
+ character.to_s.match?(WORD_CHARACTER)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,510 @@
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 = TerminalKeys::TAB
16
+ EDITOR_SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
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
+ path = @editor_state.path || @editor_state.display_path
272
+ @editor_indent_unit_path ||= nil
273
+ if @editor_indent_unit_path != path
274
+ @editor_indent_unit_path = path
275
+ @editor_indent_unit = detect_editor_indent_unit
276
+ end
277
+ @editor_indent_unit
278
+ end
279
+
280
+ def detect_editor_indent_unit
281
+ indents = @editor_state.lines.filter_map do |line|
282
+ whitespace = line[/\A[ \t]+(?=\S)/].to_s
283
+ whitespace.empty? ? nil : whitespace
284
+ end
285
+ return " " if indents.empty?
286
+
287
+ tab_count = indents.sum { |indent| indent.count("\t") }
288
+ space_count = indents.sum { |indent| indent.count(" ") }
289
+ return "\t" if tab_count > space_count
290
+
291
+ widths = indents.filter_map do |indent|
292
+ next if indent.include?("\t")
293
+
294
+ indent.length if indent.length.positive?
295
+ end
296
+ positive_deltas = widths.each_cons(2).filter_map do |previous, current|
297
+ delta = current - previous
298
+ delta.positive? ? delta : nil
299
+ end
300
+ candidates = positive_deltas.empty? ? widths : positive_deltas
301
+ detected = candidates.tally.max_by { |width, count| [count, -width] }&.first
302
+ detected && detected.positive? ? " " * detected : " "
303
+ end
304
+
305
+ def editor_line_opens_indent?(line, language)
306
+ return false unless language
307
+
308
+ code = editor_indent_code(line, language).rstrip
309
+ return false if code.empty?
310
+
311
+ return true if code.end_with?("{", "[", "(")
312
+
313
+ case language
314
+ when :ruby
315
+ editor_ruby_line_opens_indent?(code)
316
+ when :shell
317
+ editor_keyword_line_opens_indent?(code, SHELL_INDENT_KEYWORDS)
318
+ when :python
319
+ code.end_with?(":") || editor_keyword_line_opens_indent?(code, PYTHON_INDENT_KEYWORDS)
320
+ when :lua
321
+ editor_keyword_line_opens_indent?(code, LUA_INDENT_KEYWORDS)
322
+ when :yaml
323
+ code.match?(/:\s*(?:[#].*)?\z/)
324
+ when :html
325
+ editor_html_line_opens_indent?(code)
326
+ when *C_LIKE_INDENT_LANGUAGES
327
+ false
328
+ else
329
+ false
330
+ end
331
+ end
332
+
333
+ def editor_indent_code(line, language)
334
+ text = line.to_s
335
+ marker = case language
336
+ when :ruby, :python, :shell, :yaml
337
+ "#"
338
+ when :lua, :sql
339
+ "--"
340
+ else
341
+ "//"
342
+ end
343
+ index = editor_comment_index(text, marker)
344
+ index ? text[0...index].to_s : text
345
+ end
346
+
347
+ def editor_ruby_line_opens_indent?(code)
348
+ return false if code.match?(/\b(?:end|else|elsif|ensure|rescue)\b\z/)
349
+ return true if code.match?(/\A\s*(?:#{Regexp.union(RUBY_INDENT_KEYWORDS)})\b/)
350
+
351
+ code.match?(/\bdo(?:\s*\|[^|]*\|)?\s*\z/)
352
+ end
353
+
354
+ def editor_keyword_line_opens_indent?(code, keywords)
355
+ code.match?(/\b(?:#{Regexp.union(keywords)})\b\s*\z/)
356
+ end
357
+
358
+ def editor_html_line_opens_indent?(code)
359
+ tag = code.match(/<([A-Za-z][\w:-]*)(?:\s[^>]*)?>\s*\z/)
360
+ return false unless tag
361
+ return false if code.match?(/<\/[^>]+>\s*\z/)
362
+ return false if code.match?(/\/>\s*\z/)
363
+
364
+ true
365
+ end
366
+
367
+ def editor_closing_punctuation?(text)
368
+ PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
369
+ end
370
+
371
+ def editor_reindent_for_closing_punctuation(text)
372
+ return unless editor_cursor_in_leading_indent?
373
+
374
+ indent = editor_matching_punctuation_indent(text)
375
+ editor_reindent_current_line(indent) if indent
376
+ end
377
+
378
+ def editor_reindent_for_completed_word_closer
379
+ line_index, column = @editor_state.cursor_line_and_column
380
+ line = @editor_state.lines[line_index].to_s
381
+ before_cursor = line[0...column].to_s
382
+ return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
383
+
384
+ indent = editor_matching_word_indent
385
+ editor_reindent_current_line(indent) if indent
386
+ end
387
+
388
+ def editor_cursor_in_leading_indent?
389
+ line_index, column = @editor_state.cursor_line_and_column
390
+ line = @editor_state.lines[line_index].to_s
391
+ column <= line[/\A[ \t]*/].to_s.length
392
+ end
393
+
394
+ def editor_reindent_current_line(indent)
395
+ line_index, column = @editor_state.cursor_line_and_column
396
+ line_start = @editor_state.line_start_offset(line_index)
397
+ line = @editor_state.lines[line_index].to_s
398
+ old_indent = line[/\A[ \t]*/].to_s
399
+ new_indent = indent.to_s
400
+ return false if old_indent == new_indent
401
+
402
+ content_column = [column - old_indent.length, 0].max
403
+ @editor_state.replace_range(line_start, line_start + old_indent.length, new_indent)
404
+ @editor_state.cursor = line_start + new_indent.length + content_column
405
+ true
406
+ end
407
+
408
+ def editor_completed_word_closer?(text, language)
409
+ code = editor_indent_code(text, language).rstrip
410
+ case language
411
+ when :ruby
412
+ code.match?(/\A[ \t]*end\z/)
413
+ when :lua
414
+ code.match?(/\A[ \t]*(?:end|until)\z/)
415
+ when :shell
416
+ code.match?(/\A[ \t]*(?:#{Regexp.union(SHELL_DEDENT_KEYWORDS)})\z/)
417
+ when :html
418
+ code.match?(/\A[ \t]*<\/[A-Za-z][\w:-]*>\z/)
419
+ else
420
+ false
421
+ end
422
+ end
423
+
424
+ def editor_matching_word_indent
425
+ case editor_syntax_language
426
+ when :ruby
427
+ editor_matching_keyword_indent("end", %w[end])
428
+ when :lua
429
+ editor_matching_keyword_indent("end", %w[end until])
430
+ when :shell
431
+ editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS)
432
+ when :html
433
+ editor_matching_html_indent
434
+ end
435
+ end
436
+
437
+ def editor_matching_punctuation_indent(text)
438
+ opener = PUNCTUATION_PAIRS[text]
439
+ stack = []
440
+ editor_previous_code_lines.each do |line|
441
+ editor_scan_punctuation_tokens(line[:code]).each do |token|
442
+ if token == opener
443
+ stack << line[:indent]
444
+ elsif token == text
445
+ stack.pop
446
+ end
447
+ end
448
+ end
449
+ stack.last || ""
450
+ end
451
+
452
+ def editor_matching_keyword_indent(opener = nil, closers = [])
453
+ stack = []
454
+ editor_previous_code_lines.each do |line|
455
+ code = line[:code].strip
456
+ next if code.empty?
457
+
458
+ stack.pop if editor_word_closer_line?(code, closers)
459
+ stack << line[:indent] if editor_word_opener_line?(code, opener)
460
+ end
461
+ stack.last || ""
462
+ end
463
+
464
+ def editor_matching_html_indent
465
+ stack = []
466
+ editor_previous_code_lines.each do |line|
467
+ code = line[:code].strip
468
+ next if code.empty?
469
+
470
+ stack.pop if code.match?(/\A<\/[A-Za-z][\w:-]*>/)
471
+ stack << line[:indent] if editor_html_line_opens_indent?(code)
472
+ end
473
+ stack.last || ""
474
+ end
475
+
476
+ def editor_previous_code_lines
477
+ line_index, = @editor_state.cursor_line_and_column
478
+ @editor_state.lines.first(line_index).filter_map do |line|
479
+ code = editor_indent_code(line, editor_syntax_language).rstrip
480
+ next if code.strip.empty?
481
+
482
+ { indent: line[/\A[ \t]*/].to_s, code: code }
483
+ end
484
+ end
485
+
486
+ def editor_scan_punctuation_tokens(code)
487
+ code.to_s.scan(/[{}\[\]()]/)
488
+ end
489
+
490
+ def editor_word_opener_line?(code, opener)
491
+ case editor_syntax_language
492
+ when :ruby
493
+ editor_ruby_line_opens_indent?(code)
494
+ when :lua
495
+ editor_keyword_line_opens_indent?(code, LUA_INDENT_KEYWORDS)
496
+ when :shell
497
+ editor_keyword_line_opens_indent?(code, SHELL_INDENT_KEYWORDS)
498
+ else
499
+ opener && code.match?(/\b#{Regexp.escape(opener)}\b/)
500
+ end
501
+ end
502
+
503
+ def editor_word_closer_line?(code, closers)
504
+ return false if closers.empty?
505
+
506
+ code.match?(/\A(?:#{Regexp.union(closers)})\b/)
507
+ end
508
+ end
509
+ end
510
+ end