kward 0.83.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -0,0 +1,362 @@
1
+ require_relative "../../scratchpad_runner"
2
+ require_relative "runner_state"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Coordinates execution and output viewing for editor buffers.
7
+ class PromptInterface
8
+ module EditorRunner
9
+ private
10
+
11
+ def editor_runner_config
12
+ config = @editor_runners_source.respond_to?(:call) ? @editor_runners_source.call : {}
13
+ config.is_a?(Hash) ? config : {}
14
+ end
15
+
16
+ def editor_runner_output_visible?
17
+ @editor_runner_output_visible && @editor_runner_state
18
+ end
19
+
20
+ def editor_runner_running?
21
+ @editor_runner_state&.running?
22
+ end
23
+
24
+ def run_editor_buffer(source: nil, language: nil, source_path: nil, output_visible: true, completion: nil)
25
+ return false unless @editor_state
26
+ return false if @editor_state.readonly?
27
+ if editor_runner_running?
28
+ @editor_runner_output_visible = true
29
+ @editor_state.status = "Runner already active · Ctrl+C cancel"
30
+ return false
31
+ end
32
+
33
+ language ||= @editor_state.language || editor_syntax_language
34
+ unless ScratchpadLanguages.runnable?(language)
35
+ @editor_state.status = "No runner available for #{language || "text"}"
36
+ return false
37
+ end
38
+
39
+ source = source.nil? ? @editor_state.buffer.dup : source.to_s
40
+ source_path ||= @editor_state.path
41
+ workspace_root = prompt_workspace_root
42
+ state = EditorRunnerState.new(language: language, source: source)
43
+ @editor_runner_state = state
44
+ @editor_runner_completion = completion
45
+ @editor_runner_output_visible = output_visible
46
+ clear_editor_runner_selection
47
+ @editor_state.status = "Running #{language} · Ctrl+C cancel"
48
+ Thread.new do
49
+ Thread.current.report_on_exception = false
50
+ result = ScratchpadRunner.run(
51
+ language,
52
+ source,
53
+ cancelled: state.method(:cancel_requested?),
54
+ cwd: workspace_root,
55
+ source_path: source_path,
56
+ runner_config: editor_runner_config
57
+ )
58
+ finish_editor_runner(state, result)
59
+ rescue StandardError => e
60
+ fail_editor_runner(state, e)
61
+ end
62
+ true
63
+ end
64
+
65
+ def finish_editor_runner(state, result)
66
+ @mutex.synchronize do
67
+ return unless @editor_runner_state.equal?(state) && @editor_state
68
+
69
+ state.complete(result)
70
+ completion = @editor_runner_completion
71
+ @editor_runner_completion = nil
72
+ @editor_runner_output_visible = false if completion
73
+ completion ? completion.call(result, nil) : @editor_state.status = editor_runner_result_status(result)
74
+ render_prompt_locked if @started && @asking
75
+ end
76
+ end
77
+
78
+ def fail_editor_runner(state, error)
79
+ @mutex.synchronize do
80
+ return unless @editor_runner_state.equal?(state) && @editor_state
81
+
82
+ state.fail(error)
83
+ completion = @editor_runner_completion
84
+ @editor_runner_completion = nil
85
+ @editor_runner_output_visible = false if completion
86
+ if completion
87
+ completion.call(nil, error)
88
+ else
89
+ @editor_state.status = "Run failed: #{error.message}"
90
+ end
91
+ render_prompt_locked if @started && @asking
92
+ end
93
+ end
94
+
95
+ def editor_runner_result_status(result)
96
+ return "Canceled · Esc close · Ctrl+R rerun" if result.cancelled
97
+
98
+ "Exit #{result.exit_status} · #{format_runner_duration(result.duration)} · Esc close · Ctrl+R rerun"
99
+ end
100
+
101
+ def format_runner_duration(duration)
102
+ format("%.2fs", duration.to_f)
103
+ end
104
+
105
+ def cancel_editor_runner
106
+ return false unless editor_runner_running?
107
+
108
+ @editor_runner_state.cancel
109
+ @editor_state.status = "Canceling #{@editor_runner_state.language} · please wait"
110
+ true
111
+ end
112
+
113
+ def close_editor_runner_output
114
+ @editor_runner_output_visible = false
115
+ clear_editor_runner_selection
116
+ true
117
+ end
118
+
119
+ def handle_editor_runner_output_key(key)
120
+ mouse_result = handle_editor_runner_mouse_key(key)
121
+ return mouse_result unless mouse_result == false
122
+ return true if handle_bundled_key(key) { |token| handle_editor_runner_output_key(token) }
123
+
124
+ if (sequence = parse_csi_u_key(key))
125
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
126
+ if sequence[:code] == 121 && !ctrl_modifier?(sequence[:modifier]) && @editor_state&.vibe? && editor_runner_selection_active?
127
+ return copy_editor_runner_selection
128
+ end
129
+ normalized_code = ctrl_code(sequence[:code])
130
+ if (ctrl_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])) && normalized_code == 99
131
+ return copy_editor_runner_selection if editor_runner_selection_active?
132
+ return cancel_editor_runner if ctrl_modifier?(sequence[:modifier]) && editor_runner_running?
133
+ end
134
+ if ctrl_modifier?(sequence[:modifier])
135
+ return run_editor_buffer if normalized_code == 114
136
+ if normalized_code == 113
137
+ close_editor_runner_output
138
+ return quit_editor
139
+ end
140
+ end
141
+ return close_editor_runner_output if sequence[:code] == 27
142
+ end
143
+
144
+ key_name = key_name_for(key)
145
+ case key_name
146
+ when :up
147
+ scroll_editor_runner_output(-1)
148
+ when :down
149
+ scroll_editor_runner_output(1)
150
+ when :pageup
151
+ scroll_editor_runner_output(-editor_runner_output_page_size)
152
+ when :pagedown
153
+ scroll_editor_runner_output(editor_runner_output_page_size)
154
+ when :ctrl_q
155
+ close_editor_runner_output
156
+ quit_editor
157
+ when :ctrl_r
158
+ run_editor_buffer
159
+ when :ctrl_c
160
+ if editor_runner_selection_active?
161
+ copy_editor_runner_selection
162
+ else
163
+ editor_runner_running? ? cancel_editor_runner : close_editor_runner_output
164
+ end
165
+ else
166
+ case key
167
+ when "\e", "q", "Q"
168
+ close_editor_runner_output
169
+ when "y"
170
+ copy_editor_runner_selection if @editor_state&.vibe? && editor_runner_selection_active?
171
+ when TerminalKeys::CTRL_Q
172
+ close_editor_runner_output
173
+ quit_editor
174
+ when TerminalKeys::CTRL_R
175
+ run_editor_buffer
176
+ when TerminalKeys::CTRL_C
177
+ if editor_runner_selection_active?
178
+ copy_editor_runner_selection
179
+ else
180
+ editor_runner_running? ? cancel_editor_runner : close_editor_runner_output
181
+ end
182
+ end
183
+ end
184
+ true
185
+ end
186
+
187
+ def handle_editor_runner_mouse_key(key)
188
+ event = parse_sgr_mouse_event(key)
189
+ return false unless event
190
+
191
+ queue_pending_keys(event[:remaining]) unless event[:remaining].empty?
192
+ case event[:code]
193
+ when 64
194
+ scroll_editor_runner_output(-1)
195
+ when 65
196
+ scroll_editor_runner_output(1)
197
+ else
198
+ if event[:drag]
199
+ update_editor_runner_selection(editor_runner_position_for_mouse_event(event))
200
+ elsif event[:button].zero?
201
+ event[:release] ? finish_editor_runner_selection : begin_editor_runner_selection(event)
202
+ end
203
+ end
204
+ true
205
+ end
206
+
207
+ def begin_editor_runner_selection(event)
208
+ position = editor_runner_position_for_mouse_event(event)
209
+ return true unless position
210
+
211
+ click_count = editor_runner_mouse_click_count(event)
212
+ case click_count
213
+ when 3..Float::INFINITY
214
+ @editor_runner_selection_anchor = [position[0], 0]
215
+ @editor_runner_selection_cursor = [position[0], editor_runner_line(position[0]).length]
216
+ when 2
217
+ range = editor_runner_word_range_at(position)
218
+ if range
219
+ @editor_runner_selection_anchor = [position[0], range[0]]
220
+ @editor_runner_selection_cursor = [position[0], range[1]]
221
+ else
222
+ @editor_runner_selection_anchor = position
223
+ @editor_runner_selection_cursor = position
224
+ end
225
+ else
226
+ @editor_runner_selection_anchor = position
227
+ @editor_runner_selection_cursor = position
228
+ end
229
+ @editor_runner_mouse_dragging = true
230
+ true
231
+ end
232
+
233
+ def update_editor_runner_selection(position)
234
+ return true unless @editor_runner_mouse_dragging && position
235
+
236
+ @editor_runner_selection_cursor = position
237
+ true
238
+ end
239
+
240
+ def finish_editor_runner_selection
241
+ @editor_runner_mouse_dragging = false
242
+ true
243
+ end
244
+
245
+ def editor_runner_mouse_click_count(event)
246
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ count = if @editor_runner_last_click && now - @editor_runner_last_click[:time] <= 0.5 &&
248
+ (@editor_runner_last_click[:column] - event[:column]).abs <= 1 &&
249
+ (@editor_runner_last_click[:row] - event[:row]).abs <= 1
250
+ @editor_runner_last_click[:count] + 1
251
+ else
252
+ 1
253
+ end
254
+ @editor_runner_last_click = { time: now, column: event[:column], row: event[:row], count: count }
255
+ count
256
+ end
257
+
258
+ def editor_runner_position_for_mouse_event(event)
259
+ row_offset = event[:row] - editor_runner_output_content_top_row
260
+ return nil if row_offset.negative? || row_offset >= editor_runner_output_visible_count
261
+
262
+ line_index = @editor_runner_state.scroll_row + row_offset
263
+ line = editor_runner_line(line_index)
264
+ return nil unless line
265
+
266
+ column = [[event[:column] - 3, 0].max, line.length].min
267
+ [line_index, column]
268
+ end
269
+
270
+ def editor_runner_output_content_top_row
271
+ height = screen_height
272
+ source_count, = editor_split_visible_counts(height: height, width: screen_width)
273
+ composer_top_row(height) + source_count + 3
274
+ end
275
+
276
+ def editor_runner_line(line_index)
277
+ lines = editor_runner_output_lines([screen_width - 4, 1].max)
278
+ line = lines[line_index]
279
+ line && ANSI.strip(line)
280
+ end
281
+
282
+ def editor_runner_selection_active?
283
+ @editor_runner_selection_anchor && @editor_runner_selection_cursor &&
284
+ @editor_runner_selection_anchor != @editor_runner_selection_cursor
285
+ end
286
+
287
+ def clear_editor_runner_selection
288
+ @editor_runner_selection_anchor = nil
289
+ @editor_runner_selection_cursor = nil
290
+ @editor_runner_mouse_dragging = false
291
+ @editor_runner_last_click = nil
292
+ end
293
+
294
+ def editor_runner_selection_bounds
295
+ return nil unless editor_runner_selection_active?
296
+
297
+ [@editor_runner_selection_anchor, @editor_runner_selection_cursor].minmax_by { |position| position }
298
+ end
299
+
300
+ def editor_runner_selection_range_for(line_index, line_length)
301
+ bounds = editor_runner_selection_bounds
302
+ return nil unless bounds
303
+
304
+ first, last = bounds
305
+ return nil if line_index < first[0] || line_index > last[0]
306
+
307
+ start_column = line_index == first[0] ? first[1] : 0
308
+ end_column = line_index == last[0] ? last[1] : line_length
309
+ [start_column, end_column] if start_column < end_column
310
+ end
311
+
312
+ def editor_runner_word_range_at(position)
313
+ line = editor_runner_line(position[0]).to_s
314
+ return nil if line.empty? || TextBoundary.word_separator?(line[[position[1], line.length - 1].min])
315
+
316
+ index = [position[1], line.length - 1].min
317
+ start_column = index
318
+ start_column -= 1 while start_column.positive? && !TextBoundary.word_separator?(line[start_column - 1])
319
+ end_column = index + 1
320
+ end_column += 1 while end_column < line.length && !TextBoundary.word_separator?(line[end_column])
321
+ [start_column, end_column]
322
+ end
323
+
324
+ def editor_runner_selected_text
325
+ bounds = editor_runner_selection_bounds
326
+ return "" unless bounds
327
+
328
+ first, last = bounds
329
+ lines = (first[0]..last[0]).map { |line_index| editor_runner_line(line_index).to_s }
330
+ if lines.length == 1
331
+ lines[0] = lines[0][first[1]...last[1]] || ""
332
+ else
333
+ lines[0] = lines[0][first[1]..] || ""
334
+ lines[-1] = lines[-1][...last[1]] || ""
335
+ end
336
+ lines.join("\n")
337
+ end
338
+
339
+ def copy_editor_runner_selection
340
+ text = editor_runner_selected_text
341
+ return false if text.empty?
342
+
343
+ print_output_locked(TerminalSequences.osc52(text))
344
+ flush_output_locked if @output_io.respond_to?(:flush)
345
+ clear_editor_runner_selection
346
+ @editor_state.status = "Copied selection"
347
+ true
348
+ end
349
+
350
+ def scroll_editor_runner_output(delta)
351
+ return false unless @editor_runner_state
352
+
353
+ @editor_runner_state.scroll_by(delta, maximum: editor_runner_output_scroll_max)
354
+ true
355
+ end
356
+
357
+ def editor_runner_output_page_size
358
+ [editor_runner_output_visible_count, 1].max
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,78 @@
1
+ require "digest"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Ephemeral state for the output of an editor runner.
6
+ class PromptInterface
7
+ class EditorRunnerState
8
+ attr_reader :language, :source_digest
9
+
10
+ def initialize(language:, source:)
11
+ @language = language.to_sym
12
+ @source_digest = Digest::SHA256.hexdigest(source.to_s)
13
+ @mutex = Mutex.new
14
+ @running = true
15
+ @cancel_requested = false
16
+ @result = nil
17
+ @error = nil
18
+ @scroll_row = 0
19
+ end
20
+
21
+ def running?
22
+ @mutex.synchronize { @running }
23
+ end
24
+
25
+ def cancel_requested?
26
+ @mutex.synchronize { @cancel_requested }
27
+ end
28
+
29
+ def cancel
30
+ @mutex.synchronize { @cancel_requested = true }
31
+ end
32
+
33
+ def complete(result)
34
+ @mutex.synchronize do
35
+ @result = result
36
+ @running = false
37
+ end
38
+ end
39
+
40
+ def fail(error)
41
+ @mutex.synchronize do
42
+ @error = error
43
+ @running = false
44
+ end
45
+ end
46
+
47
+ def result
48
+ @mutex.synchronize { @result }
49
+ end
50
+
51
+ def error
52
+ @mutex.synchronize { @error }
53
+ end
54
+
55
+ def output_text
56
+ @mutex.synchronize do
57
+ return @error.message.to_s if @error
58
+
59
+ @result&.output.to_s
60
+ end
61
+ end
62
+
63
+ def scroll_row
64
+ @mutex.synchronize { @scroll_row }
65
+ end
66
+
67
+ def set_scroll_row(row, maximum:)
68
+ @mutex.synchronize do
69
+ @scroll_row = [[row.to_i, 0].max, maximum.to_i].min
70
+ end
71
+ end
72
+
73
+ def scroll_by(delta, maximum:)
74
+ set_scroll_row(scroll_row + delta.to_i, maximum: maximum)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -180,6 +180,15 @@ module Kward
180
180
  sync_vibe_state
181
181
  end
182
182
 
183
+ def vibe_command_range
184
+ @vibe_state.command_range
185
+ end
186
+
187
+ def vibe_command_range=(value)
188
+ @vibe_state.command_range = value
189
+ sync_vibe_state
190
+ end
191
+
183
192
  def vibe_last_change
184
193
  @vibe_state.last_change
185
194
  end
@@ -207,15 +216,6 @@ module Kward
207
216
  sync_vibe_state
208
217
  end
209
218
 
210
- def vibe_visual_block_insert
211
- @vibe_state.visual_block_insert
212
- end
213
-
214
- def vibe_visual_block_insert=(value)
215
- @vibe_state.visual_block_insert = value
216
- sync_vibe_state
217
- end
218
-
219
219
  def vibe_marks
220
220
  @vibe_state.marks
221
221
  end
@@ -1289,10 +1289,10 @@ module Kward
1289
1289
  @vibe_mode = @vibe_state.mode
1290
1290
  @vibe_pending = @vibe_state.pending
1291
1291
  @vibe_command = @vibe_state.command
1292
+ @vibe_command_range = @vibe_state.command_range
1292
1293
  @vibe_last_change = @vibe_state.last_change
1293
1294
  @vibe_last_find = @vibe_state.last_find
1294
1295
  @vibe_last_visual_selection = @vibe_state.last_visual_selection
1295
- @vibe_visual_block_insert = @vibe_state.visual_block_insert
1296
1296
  @vibe_marks = @vibe_state.marks
1297
1297
  @vibe_registers = @vibe_state.registers
1298
1298
  @vibe_register_types = @vibe_state.register_types
@@ -53,6 +53,7 @@ module Kward
53
53
  keywords: %w[begin if while for try let quote function macro module baremodule struct mutable abstract primitive type do end else elseif catch finally true false nothing]
54
54
  },
55
55
  makefile: {
56
+ extensions: %w[.mk],
56
57
  filenames: %w[Makefile makefile GNUmakefile],
57
58
  line_comment: "#",
58
59
  keywords: %w[ifeq ifneq ifdef ifndef else endif include define endef export unexport override]
@@ -125,11 +126,17 @@ module Kward
125
126
  def editor_highlight_line(line, line_index = nil)
126
127
  return line.to_s unless @color_enabled
127
128
 
128
- case editor_syntax_language
129
+ if editor_syntax_language == :markdown
130
+ editor_highlight_markdown(line, line_index)
131
+ else
132
+ editor_highlight_language_line(line, editor_syntax_language, line_index)
133
+ end
134
+ end
135
+
136
+ def editor_highlight_language_line(line, language, line_index = nil)
137
+ case language
129
138
  when :ruby
130
139
  editor_highlight_ruby(line, line_index)
131
- when :markdown
132
- editor_highlight_markdown(line)
133
140
  when :json
134
141
  editor_highlight_json(line)
135
142
  when :yaml
@@ -143,7 +150,7 @@ module Kward
143
150
  when :sql
144
151
  editor_highlight_sql(line)
145
152
  else
146
- editor_highlight_generic(line, editor_syntax_language, line_index)
153
+ editor_highlight_generic(line, language, line_index)
147
154
  end
148
155
  end
149
156
 
@@ -395,16 +402,71 @@ module Kward
395
402
  nil
396
403
  end
397
404
 
398
- def editor_highlight_markdown(line)
405
+ def editor_highlight_markdown(line, line_index = nil)
399
406
  text = line.to_s
407
+ fence_context = editor_markdown_fence_context(line_index)
408
+ return editor_highlight_markdown_fence(text) if fence_context == :marker
409
+ if fence_context.is_a?(Array)
410
+ language, nested_line_index = fence_context
411
+ return editor_highlight_language_line(text, language, nested_line_index) if language
412
+
413
+ return text
414
+ end
415
+
400
416
  return editor_highlight_markdown_heading(text) if text.match?(/\A\s{0,3}[#]{1,6}\s/)
401
- return editor_highlight_markdown_fence(text) if text.match?(/\A\s*```/)
402
417
  return editor_highlight_markdown_blockquote(text) if text.match?(/\A\s*>/)
403
418
  return editor_highlight_markdown_list(text) if text.match?(/\A\s*(?:[-*+]\s+|\d+\.\s+)/)
404
419
 
405
420
  editor_highlight_markdown_inline(text)
406
421
  end
407
422
 
423
+ def editor_markdown_fence_context(line_index)
424
+ return nil unless line_index && @editor_state
425
+
426
+ inside_fence = false
427
+ language = nil
428
+ opening_index = nil
429
+ @editor_state.lines.first(line_index.to_i + 1).each_with_index do |line, index|
430
+ match = line.to_s.match(/\A\s*```([^`]*)\s*\z/)
431
+ if match
432
+ if inside_fence
433
+ inside_fence = false
434
+ language = nil
435
+ opening_index = nil
436
+ else
437
+ inside_fence = true
438
+ language = editor_markdown_fence_language(match[1])
439
+ opening_index = index
440
+ end
441
+ return :marker if index == line_index
442
+ elsif inside_fence && index == line_index
443
+ return [language, index, opening_index]
444
+ end
445
+ end
446
+
447
+ nil
448
+ end
449
+
450
+ def editor_effective_syntax_language(line_index = nil)
451
+ language = editor_syntax_language
452
+ return language unless language == :markdown
453
+
454
+ context = editor_markdown_fence_context(line_index)
455
+ context.is_a?(Array) ? context.first || :markdown : :markdown
456
+ end
457
+
458
+ def editor_syntax_context_start(line_index = nil)
459
+ return nil unless editor_syntax_language == :markdown
460
+
461
+ context = editor_markdown_fence_context(line_index)
462
+ context.is_a?(Array) ? context[2] : nil
463
+ end
464
+
465
+ def editor_markdown_fence_language(info)
466
+ name = info.to_s.strip.split(/\s/, 2).first.to_s.downcase
467
+ ScratchpadLanguages::ALIASES[name]
468
+ end
469
+
408
470
  def editor_highlight_markdown_heading(line)
409
471
  line.sub(/\A(\s{0,3}[#]{1,6}\s+)(.*)\z/) do
410
472
  "#{colored(Regexp.last_match(1), :cyan)}#{colored(Regexp.last_match(2), :bold)}"
@@ -5,7 +5,7 @@ module Kward
5
5
  # Modal Vibe editor state for one editor buffer.
6
6
  class VibeEditorState
7
7
  attr_accessor :mode, :pending, :command, :last_change, :last_find
8
- attr_accessor :last_visual_selection, :visual_block_insert
8
+ attr_accessor :command_range, :last_visual_selection
9
9
  attr_accessor :marks, :registers, :register_types, :macros, :recording_macro, :last_macro
10
10
  attr_accessor :kill_linewise, :previous_change_cursor, :jump_back_list, :jump_forward_list
11
11
 
@@ -13,10 +13,10 @@ module Kward
13
13
  @mode = editor_mode == "vibe" ? "normal" : nil
14
14
  @pending = ""
15
15
  @command = ""
16
+ @command_range = nil
16
17
  @last_change = nil
17
18
  @last_find = nil
18
19
  @last_visual_selection = nil
19
- @visual_block_insert = nil
20
20
  @marks = {}
21
21
  @registers = {}
22
22
  @register_types = {}
@@ -34,10 +34,10 @@ module Kward
34
34
  state.mode = other.mode&.dup
35
35
  state.pending = other.pending.dup
36
36
  state.command = other.command.dup
37
+ state.command_range = other.command_range&.dup
37
38
  state.last_change = other.last_change&.dup
38
39
  state.last_find = other.last_find&.dup
39
40
  state.last_visual_selection = other.last_visual_selection&.dup
40
- state.visual_block_insert = other.visual_block_insert&.dup
41
41
  state.marks = other.marks.transform_values(&:dup)
42
42
  state.registers = other.registers.transform_values(&:dup)
43
43
  state.register_types = other.register_types.transform_values(&:dup)