kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -8,21 +8,34 @@ module Kward
8
8
  module ProjectFiles
9
9
  module_function
10
10
 
11
- def list(root: Dir.pwd)
12
- paths = git_paths(root)
13
- paths = scanned_paths(root) if paths.empty?
11
+ def list(root: Dir.pwd, include_ignored: false)
12
+ paths = git_paths(root, include_ignored: include_ignored)
13
+ paths = scanned_paths(root) if paths.empty? && !git_repository?(root)
14
14
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
15
15
  end
16
16
 
17
- def git_paths(root)
18
- output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
17
+ def git_paths(root, include_ignored: false)
18
+ output, status = Open3.capture2e("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
19
  return [] unless status.success?
20
20
 
21
- output.lines.map(&:chomp).reject(&:empty?)
21
+ paths = output.lines.map(&:chomp).reject(&:empty?)
22
+ return paths unless include_ignored
23
+
24
+ ignored_output, ignored_status = Open3.capture2e("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
25
+ return paths unless ignored_status.success?
26
+
27
+ paths + ignored_output.lines.map(&:chomp).reject(&:empty?)
22
28
  rescue StandardError
23
29
  []
24
30
  end
25
31
 
32
+ def git_repository?(root)
33
+ _output, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: root)
34
+ status.success?
35
+ rescue StandardError
36
+ false
37
+ end
38
+
26
39
  def scanned_paths(root)
27
40
  root_path = Pathname.new(root)
28
41
  paths = []
@@ -1,3 +1,5 @@
1
+ require_relative "../terminal_text"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Renderer for the editable composer text area.
@@ -8,7 +10,7 @@ module Kward
8
10
 
9
11
  def composer_layout(width, height = screen_height)
10
12
  return interactive_layout(width, height) if interactive_active_locked?
11
- return editor_layout(width, height) if editor_active?
13
+ return editor_layout(width, height) if editor_visible?
12
14
  return compact_composer_layout(width) if height < 4
13
15
  return question_composer_layout(width, height) if @question_state
14
16
 
@@ -35,45 +37,42 @@ module Kward
35
37
  end
36
38
 
37
39
  def compact_composer_layout(width)
38
- cursor_line, cursor_col = cursor_logical_position
40
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
39
41
  prefix = "#{@prompt_label} "
40
- line = input_lines[cursor_line] || ""
41
- input_width = [width - prefix.length, 1].max
42
- visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
43
- visible = line[visible_start, input_width].to_s
44
- row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
45
- [[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
42
+ prefix_width = TerminalText.width(prefix)
43
+ line = lines[cursor_line] || ""
44
+ input_width = [width - prefix_width, 1].max
45
+ layout = TerminalText.wrap(line, width: input_width, cursor: cursor_col)
46
+ visible = layout[:rows][layout[:cursor_row]].to_s
47
+ row = visible_ljust(TerminalText.truncate("#{prefix}#{visible}", width), width)
48
+ [[row], 0, [prefix_width + layout[:cursor_col], width - 1].min]
46
49
  end
47
50
 
48
51
  def input_layout(content_width)
49
- cursor_line, cursor_col = cursor_logical_position
52
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
50
53
  rows = []
51
54
  cursor_row = 0
55
+ cursor_col_in_row = 0
52
56
  rendered_row_offset = 0
53
57
 
54
- input_lines.each_with_index do |line, index|
58
+ lines.each_with_index do |line, index|
55
59
  prefix = input_prefix(index)
56
- continuation_prefix = " " * prefix.length
57
- available = [content_width - prefix.length, 1].max
58
- chunks = line.scan(/.{1,#{available}}/m)
59
- chunks = [""] if chunks.empty?
60
- if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
61
- chunks << ""
62
- end
60
+ prefix_width = TerminalText.width(prefix)
61
+ continuation_prefix = " " * prefix_width
62
+ available = [content_width - prefix_width, 1].max
63
+ layout = TerminalText.wrap(line, width: available, cursor: index == cursor_line ? cursor_col : nil)
63
64
 
64
65
  if index == cursor_line
65
- cursor_row = rendered_row_offset + (cursor_col / available)
66
+ cursor_row = rendered_row_offset + layout[:cursor_row]
67
+ cursor_col_in_row = prefix_width + layout[:cursor_col]
66
68
  end
67
69
 
68
- chunks.each_with_index do |chunk, chunk_index|
70
+ layout[:rows].each_with_index do |chunk, chunk_index|
69
71
  rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
70
72
  end
71
- rendered_row_offset += chunks.length
73
+ rendered_row_offset += layout[:rows].length
72
74
  end
73
75
 
74
- prefix = input_prefix(cursor_line)
75
- available = [content_width - prefix.length, 1].max
76
- cursor_col_in_row = prefix.length + (cursor_col % available)
77
76
  [rows, cursor_row, cursor_col_in_row]
78
77
  end
79
78
 
@@ -81,23 +80,28 @@ module Kward
81
80
  title = composer_title
82
81
  status = cached_composer_status_text
83
82
  if status
84
- gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
83
+ gap = width - 2 - TerminalText.width(ANSI.strip(title)) - TerminalText.width(ANSI.strip(status))
85
84
  if gap >= 0
86
85
  return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
87
86
  end
88
87
  end
89
88
  plain_title = ANSI.strip(title)
90
- "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
89
+ "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - TerminalText.width(plain_title) - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
91
90
  end
92
91
 
93
92
  def composer_title
94
93
  label = composer_title_label
95
94
  if @busy && @queued_count.positive?
96
- status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
95
+ status_composer_text(busy_title("#{label} · #{colored("#{@queued_count} queued", :metadata)}"))
97
96
  elsif @busy && @steered_count.to_i.positive?
98
- status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
97
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} steering"))
98
+ elsif @busy && response_arriving?
99
+ status_composer_text(busy_title("#{label} · #{colored("✦", :activity, :bold)} responding"))
99
100
  elsif @busy
100
- status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
101
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} #{@busy_activity}"))
102
+ elsif @completion_status
103
+ completion = colored(@completion_status[:text], @completion_status[:style], :bold)
104
+ status_composer_text("#{label} · #{completion}")
101
105
  else
102
106
  status_composer_text(label)
103
107
  end
@@ -110,6 +114,8 @@ module Kward
110
114
  end
111
115
 
112
116
  def busy_title(text)
117
+ elapsed = busy_elapsed_text
118
+ text = "#{text} · #{colored(elapsed, :metadata)}" if elapsed
113
119
  @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
114
120
  end
115
121
 
@@ -140,7 +146,7 @@ module Kward
140
146
  label_left = 4
141
147
  @tabs.each_with_index.map do |label, index|
142
148
  text = tab_label(label, index)
143
- width = ANSI.strip(text).length
149
+ width = TerminalText.width(ANSI.strip(text))
144
150
  slot = {
145
151
  left: label_left - 2,
146
152
  label_left: label_left,
@@ -185,18 +191,25 @@ module Kward
185
191
 
186
192
  visible_offset = 0
187
193
  last_index = nil
188
- text.to_s.scan(/\e\[[0-9;:]*m|./m).each do |part|
194
+ text.to_s.scan(/\e\[[0-9;:]*m|\X/m).each do |part|
189
195
  if part.start_with?("\e")
190
196
  index = visible_offset.positive? ? last_index : left
191
197
  row[index] = row[index].to_s + part if index&.between?(0, row.length - 1)
192
198
  next
193
199
  end
194
200
 
201
+ part_width = TerminalText.width(part)
202
+ if part_width.zero?
203
+ row[last_index] = row[last_index].to_s + part if last_index
204
+ next
205
+ end
206
+
195
207
  index = left + visible_offset
196
208
  break if index >= row.length
197
209
  row[index] = row[index].to_s.sub(/\A /, "") + part unless index.negative?
210
+ 1.upto(part_width - 1) { |offset| row[index + offset] = "" if (index + offset).between?(0, row.length - 1) }
198
211
  last_index = index
199
- visible_offset += 1
212
+ visible_offset += part_width
200
213
  end
201
214
  end
202
215
 
@@ -252,19 +265,10 @@ module Kward
252
265
  [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
253
266
  end
254
267
 
255
- def input_lines
256
- lines = composer_input.split("\n", -1)
257
- lines.empty? ? [""] : lines
258
- end
259
-
260
268
  def input_prefix(_index)
261
269
  ""
262
270
  end
263
271
 
264
- def cursor_logical_position
265
- @composer.cursor_logical_position
266
- end
267
-
268
272
  def image_viewer_preview_rows(height, width: screen_width)
269
273
  available_rows = max_project_browser_rows(height, width: width)
270
274
  desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
@@ -276,7 +280,7 @@ module Kward
276
280
  preview_rows = image_viewer_preview_rows(height, width: width)
277
281
  title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
282
  rows = [
279
- colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
283
+ colored("╭", :primary_green) + title + colored("─" * [width - TerminalText.width(ANSI.strip(title)) - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
284
  image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
285
  ]
282
286
  rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
@@ -1,4 +1,6 @@
1
1
  require_relative "../text_boundary"
2
+ require_relative "../text_matcher"
3
+ require_relative "../terminal_text"
2
4
 
3
5
  # Namespace for the Kward CLI agent runtime.
4
6
  module Kward
@@ -41,6 +43,8 @@ module Kward
41
43
  @history_search_query = nil
42
44
  @history_search_draft = nil
43
45
  @history_search_index = 0
46
+ @history_search_matches_query = nil
47
+ @history_search_matches = nil
44
48
  end
45
49
 
46
50
  # Removes all pending attachments without changing text input.
@@ -76,31 +80,33 @@ module Kward
76
80
  @cursor += string.length
77
81
  end
78
82
 
79
- # Deletes one character before the cursor.
83
+ # Deletes one grapheme before the cursor.
80
84
  def delete_before_cursor
81
85
  return false if @cursor.zero?
82
86
 
83
- @input = @input[0...(@cursor - 1)] + @input[@cursor..]
84
- @cursor -= 1
87
+ previous = TerminalText.previous_grapheme_boundary(@input, @cursor)
88
+ @input = @input[0...previous] + @input[@cursor..]
89
+ @cursor = previous
85
90
  true
86
91
  end
87
92
 
88
- # Deletes one character at the cursor without moving it.
93
+ # Deletes one grapheme at the cursor without moving it.
89
94
  def delete_at_cursor
90
95
  return false unless @cursor < @input.length
91
96
 
92
- @input = @input[0...@cursor] + @input[(@cursor + 1)..]
97
+ following = TerminalText.next_grapheme_boundary(@input, @cursor)
98
+ @input = @input[0...@cursor] + @input[following..]
93
99
  true
94
100
  end
95
101
 
96
- # Moves the cursor one character left when possible.
102
+ # Moves the cursor one grapheme left when possible.
97
103
  def move_cursor_left
98
- @cursor -= 1 if @cursor.positive?
104
+ @cursor = TerminalText.previous_grapheme_boundary(@input, @cursor) if @cursor.positive?
99
105
  end
100
106
 
101
- # Moves the cursor one character right when possible.
107
+ # Moves the cursor one grapheme right when possible.
102
108
  def move_cursor_right
103
- @cursor += 1 if @cursor < @input.length
109
+ @cursor = TerminalText.next_grapheme_boundary(@input, @cursor) if @cursor < @input.length
104
110
  end
105
111
 
106
112
  # Moves the cursor to the beginning of the input buffer.
@@ -164,10 +170,14 @@ module Kward
164
170
  @cursor = @input.length
165
171
  end
166
172
 
167
- # Returns `[row, column]` for cursor placement in multi-line input.
168
- def cursor_logical_position
173
+ # Returns `[lines, row, column]` for cursor-aware multi-line layout.
174
+ def lines_and_cursor
175
+ lines = @input.split("\n", -1)
169
176
  before_cursor = @input[0...@cursor]
170
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
177
+ row = before_cursor.count("\n")
178
+ line_start = before_cursor.rindex("\n")
179
+ column = line_start ? before_cursor.length - line_start - 1 : before_cursor.length
180
+ [lines.empty? ? [""] : lines, row, column]
171
181
  end
172
182
 
173
183
  # Replaces the in-memory history list with persisted entries.
@@ -175,6 +185,7 @@ module Kward
175
185
  @history = Array(values).map(&:to_s).reject { |value| value.strip.empty? }
176
186
  reset_history_navigation
177
187
  reset_history_search
188
+ invalidate_history_search_matches
178
189
  end
179
190
 
180
191
  # Stores a submitted input unless it is blank or duplicates the previous entry.
@@ -184,6 +195,7 @@ module Kward
184
195
  return false if @history.last == value
185
196
 
186
197
  @history << value
198
+ invalidate_history_search_matches
187
199
  true
188
200
  end
189
201
 
@@ -234,9 +246,13 @@ module Kward
234
246
 
235
247
  def history_search_matches
236
248
  query = @history_search_query.to_s.downcase
237
- return @history.reverse if query.empty?
249
+ return @history_search_matches if @history_search_matches_query == query && @history_search_matches
238
250
 
239
- @history.reverse.select { |value| fuzzy_history_match?(value.downcase, query) }
251
+ pattern = TextMatcher.subsequence_pattern(query)
252
+ @history_search_matches_query = query
253
+ @history_search_matches = @history.reverse.select do |value|
254
+ TextMatcher.subsequence?(value.downcase, query, pattern)
255
+ end
240
256
  end
241
257
 
242
258
  def selected_history_search_match
@@ -274,16 +290,9 @@ module Kward
274
290
  @history_search_index = 0
275
291
  end
276
292
 
277
- def fuzzy_history_match?(value, query)
278
- query.chars.all? do |char|
279
- index = value.index(char)
280
- if index
281
- value = value[(index + 1)..].to_s
282
- true
283
- else
284
- false
285
- end
286
- end
293
+ def invalidate_history_search_matches
294
+ @history_search_matches_query = nil
295
+ @history_search_matches = nil
287
296
  end
288
297
  end
289
298
  end
@@ -83,9 +83,12 @@ module Kward
83
83
 
84
84
  def editor_insert_tab
85
85
  if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
86
+ reset_editor_word_completion
86
87
  return @editor_state.replace_selections(editor_indent_unit)
87
88
  end
88
89
 
90
+ return true if editor_complete_buffer_word
91
+
89
92
  if current_editor_auto_indent? && editor_cursor_in_leading_indent?
90
93
  return editor_smart_tab_forward
91
94
  end
@@ -142,7 +145,7 @@ module Kward
142
145
  line = @editor_state.lines[line_index].to_s
143
146
  before_cursor = line[0...column].to_s
144
147
  base_indent = line[/\A[ \t]*/].to_s
145
- language = editor_syntax_language
148
+ language = editor_effective_syntax_language(line_index)
146
149
  indent = base_indent.dup
147
150
  indent += editor_indent_unit if editor_line_opens_indent?(before_cursor, language)
148
151
  indent
@@ -176,7 +179,8 @@ module Kward
176
179
 
177
180
  def editor_expected_indent_for_line(line_index)
178
181
  line = @editor_state.lines[line_index].to_s
179
- code = editor_indent_code(line, editor_syntax_language).strip
182
+ language = editor_effective_syntax_language(line_index)
183
+ code = editor_indent_code(line, language).strip
180
184
  matching_indent = editor_matching_indent_for_line(code, line_index)
181
185
  return matching_indent if matching_indent
182
186
 
@@ -184,7 +188,7 @@ module Kward
184
188
  return "" unless previous_line
185
189
 
186
190
  indent = previous_line[:indent].dup
187
- indent += editor_indent_unit if editor_line_opens_indent?(previous_line[:code], editor_syntax_language)
191
+ indent += editor_indent_unit if editor_line_opens_indent?(previous_line[:code], previous_line[:language])
188
192
  indent
189
193
  end
190
194
 
@@ -192,18 +196,12 @@ module Kward
192
196
  return nil if code.empty?
193
197
  return editor_matching_punctuation_indent(code[0], line_index) if editor_closing_punctuation?(code[0])
194
198
 
195
- editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, editor_syntax_language)
199
+ language = editor_effective_syntax_language(line_index)
200
+ editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, language)
196
201
  end
197
202
 
198
203
  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
204
+ editor_previous_code_lines(line_index).last
207
205
  end
208
206
 
209
207
  def editor_update_current_line_indent(indent, preserve_content_column: false)
@@ -245,7 +243,7 @@ module Kward
245
243
  line = @editor_state.lines[line_index].to_s
246
244
  before_cursor = line[0...column].to_s
247
245
  base_indent = line[/\A[ \t]*/].to_s
248
- language = editor_syntax_language
246
+ language = editor_effective_syntax_language(line_index)
249
247
  opens_indent = editor_line_opens_indent?(before_cursor, language)
250
248
  paired_closer = editor_next_paired_closer
251
249
  return nil unless paired_closer
@@ -407,7 +405,8 @@ module Kward
407
405
  end
408
406
 
409
407
  def editor_closing_punctuation?(text)
410
- PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
408
+ line_index = @editor_state.cursor_line_and_column.first
409
+ PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_effective_syntax_language(line_index))
411
410
  end
412
411
 
413
412
  def editor_reindent_for_closing_punctuation(text)
@@ -422,7 +421,7 @@ module Kward
422
421
  line_index, column = @editor_state.cursor_line_and_column
423
422
  line = @editor_state.lines[line_index].to_s
424
423
  before_cursor = line[0...column].to_s
425
- return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
424
+ return unless editor_completed_word_closer?(before_cursor, editor_effective_syntax_language(line_index))
426
425
 
427
426
  indent = editor_matching_word_indent(line_index)
428
427
  editor_reindent_current_line(indent) if indent
@@ -474,7 +473,8 @@ module Kward
474
473
  end
475
474
 
476
475
  def editor_matching_word_indent(line_index = nil)
477
- case editor_syntax_language
476
+ line_index ||= @editor_state.cursor_line_and_column.first
477
+ case editor_effective_syntax_language(line_index)
478
478
  when :ruby
479
479
  editor_matching_keyword_indent("end", %w[end], line_index)
480
480
  when :lua
@@ -510,7 +510,7 @@ module Kward
510
510
  next if code.empty?
511
511
 
512
512
  stack.pop if editor_word_closer_line?(code, closers)
513
- stack << line[:indent] if editor_word_opener_line?(code, opener)
513
+ stack << line[:indent] if editor_word_opener_line?(code, opener, line[:language])
514
514
  end
515
515
  stack.last || ""
516
516
  end
@@ -529,11 +529,16 @@ module Kward
529
529
 
530
530
  def editor_previous_code_lines(line_index = nil)
531
531
  line_index ||= @editor_state.cursor_line_and_column.first
532
- @editor_state.lines.first(line_index).filter_map do |line|
533
- code = editor_indent_code(line, editor_syntax_language).rstrip
532
+ language = editor_effective_syntax_language(line_index)
533
+ first_index = editor_syntax_context_start(line_index)
534
+ first_index = 0 unless first_index && language != :markdown
535
+
536
+ (first_index...line_index).filter_map do |index|
537
+ line = @editor_state.lines[index].to_s
538
+ code = editor_indent_code(line, language).rstrip
534
539
  next if code.strip.empty?
535
540
 
536
- { indent: line[/\A[ \t]*/].to_s, code: code }
541
+ { indent: line[/\A[ \t]*/].to_s, code: code, language: language }
537
542
  end
538
543
  end
539
544
 
@@ -541,8 +546,8 @@ module Kward
541
546
  code.to_s.scan(/[{}\[\]()]/)
542
547
  end
543
548
 
544
- def editor_word_opener_line?(code, opener)
545
- case editor_syntax_language
549
+ def editor_word_opener_line?(code, opener, language)
550
+ case language
546
551
  when :ruby
547
552
  editor_ruby_line_opens_indent?(code)
548
553
  when :lua