kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -323,6 +323,8 @@ module Kward
323
323
  code.match?(/:\s*(?:[#].*)?\z/)
324
324
  when :html
325
325
  editor_html_line_opens_indent?(code)
326
+ when :erb
327
+ editor_erb_line_opens_indent?(code)
326
328
  when *C_LIKE_INDENT_LANGUAGES
327
329
  false
328
330
  else
@@ -364,6 +366,46 @@ module Kward
364
366
  true
365
367
  end
366
368
 
369
+ def editor_erb_line_opens_indent?(code)
370
+ segments, = editor_erb_segments(code.to_s, :template)
371
+ segments.any? do |kind, text|
372
+ case kind
373
+ when :ruby
374
+ editor_ruby_line_opens_indent?(text.strip)
375
+ when :template
376
+ editor_html_line_opens_indent?(text.strip)
377
+ else
378
+ false
379
+ end
380
+ end
381
+ end
382
+
383
+ def editor_erb_line_closer?(code)
384
+ segments, = editor_erb_segments(code.to_s, :template)
385
+ segments.any? do |kind, text|
386
+ case kind
387
+ when :ruby
388
+ text.strip.match?(/\Aend\z/)
389
+ when :template
390
+ text.strip.match?(/\A<\/[A-Za-z][\w:-]*>\z/)
391
+ else
392
+ false
393
+ end
394
+ end
395
+ end
396
+
397
+ def editor_matching_erb_indent(line_index = nil)
398
+ stack = []
399
+ editor_previous_code_lines(line_index).each do |line|
400
+ code = line[:code].strip
401
+ next if code.empty?
402
+
403
+ stack.pop if editor_erb_line_closer?(code)
404
+ stack << line[:indent] if editor_erb_line_opens_indent?(code)
405
+ end
406
+ stack.last || ""
407
+ end
408
+
367
409
  def editor_closing_punctuation?(text)
368
410
  PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
369
411
  end
@@ -424,6 +466,8 @@ module Kward
424
466
  code.match?(/\A[ \t]*(?:#{Regexp.union(SHELL_DEDENT_KEYWORDS)})\z/)
425
467
  when :html
426
468
  code.match?(/\A[ \t]*<\/[A-Za-z][\w:-]*>\z/)
469
+ when :erb
470
+ editor_erb_line_closer?(code)
427
471
  else
428
472
  false
429
473
  end
@@ -439,6 +483,8 @@ module Kward
439
483
  editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
440
484
  when :html
441
485
  editor_matching_html_indent(line_index)
486
+ when :erb
487
+ editor_matching_erb_indent(line_index)
442
488
  end
443
489
  end
444
490
 
@@ -47,20 +47,22 @@ module Kward
47
47
  end
48
48
 
49
49
  def line_and_column_for(offset)
50
- before_cursor = before(offset)
51
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
50
+ offset = [[offset.to_i, 0].max, @text.length].min
51
+ next_line = line_start_offsets.bsearch_index { |line_offset| line_offset > offset }
52
+ line_index = next_line ? [next_line - 1, 0].max : lines.length - 1
53
+ [line_index, [offset - line_start_offsets[line_index], lines[line_index].length].min]
52
54
  end
53
55
 
54
56
  def offset_for_line_and_column(line_index, column)
55
57
  values = lines
56
58
  line_index = [[line_index.to_i, 0].max, values.length - 1].min
57
59
  column = [[column.to_i, 0].max, values[line_index].length].min
58
- values.first(line_index).sum { |line| line.length + 1 } + column
60
+ line_start_offsets[line_index] + column
59
61
  end
60
62
 
61
63
  def line_start_offset(line_index)
62
64
  line_index = [[line_index.to_i, 0].max, lines.length - 1].min
63
- lines.first(line_index).sum { |line| line.length + 1 }
65
+ line_start_offsets[line_index]
64
66
  end
65
67
 
66
68
  def line_range(line_index)
@@ -101,8 +103,20 @@ module Kward
101
103
 
102
104
  private
103
105
 
106
+ def line_start_offsets
107
+ @line_start_offsets ||= begin
108
+ values = lines
109
+ offsets = [0]
110
+ (values.length - 1).times do |index|
111
+ offsets << offsets.last + values[index].length + 1
112
+ end
113
+ offsets
114
+ end
115
+ end
116
+
104
117
  def invalidate_lines_cache
105
118
  @lines_cache = nil
119
+ @line_start_offsets = nil
106
120
  end
107
121
  end
108
122
  end
@@ -39,8 +39,8 @@ module Kward
39
39
  end
40
40
 
41
41
  def history_file_open_command(path)
42
- full_path = File.expand_path(path.to_s, Dir.pwd)
43
- relative_path = Pathname.new(full_path).relative_path_from(Pathname.new(File.expand_path(Dir.pwd))).to_s
42
+ full_path = File.expand_path(path.to_s, prompt_workspace_root)
43
+ relative_path = Pathname.new(full_path).relative_path_from(Pathname.new(prompt_workspace_root)).to_s
44
44
  "$#{relative_path}"
45
45
  rescue ArgumentError
46
46
  "$#{path}"
@@ -71,9 +71,9 @@ module Kward
71
71
  true
72
72
  end
73
73
 
74
- def open_editor(path, allow_new: false, base_dir: Dir.pwd, restrict_to_workspace: true)
75
- full_path = File.expand_path(path.to_s, base_dir)
76
- root = File.expand_path(Dir.pwd)
74
+ def open_editor(path, allow_new: false, base_dir: nil, restrict_to_workspace: true)
75
+ full_path = File.expand_path(path.to_s, base_dir || prompt_workspace_root)
76
+ root = prompt_workspace_root
77
77
  if restrict_to_workspace && !(full_path == root || full_path.start_with?("#{root}/"))
78
78
  @file_editor_open_status = "Cannot edit file outside workspace"
79
79
  return false
@@ -1301,8 +1301,8 @@ module Kward
1301
1301
  end
1302
1302
 
1303
1303
  def bind_editor_save_path(path)
1304
- full_path = File.expand_path(path.to_s.strip, Dir.pwd)
1305
- root = File.expand_path(Dir.pwd)
1304
+ full_path = File.expand_path(path.to_s.strip, prompt_workspace_root)
1305
+ root = prompt_workspace_root
1306
1306
  unless full_path == root || full_path.start_with?("#{root}/")
1307
1307
  @editor_state.status = "Cannot save outside workspace"
1308
1308
  return false
@@ -280,8 +280,8 @@ module Kward
280
280
 
281
281
  def vibe_path_completion_candidates(prefix)
282
282
  directory_prefix, basename_prefix = vibe_split_path_completion_prefix(prefix)
283
- search_directory = File.expand_path(directory_prefix.empty? ? "." : directory_prefix, Dir.pwd)
284
- root = File.expand_path(Dir.pwd)
283
+ search_directory = File.expand_path(directory_prefix.empty? ? "." : directory_prefix, prompt_workspace_root)
284
+ root = prompt_workspace_root
285
285
  return [] unless search_directory == root || search_directory.start_with?("#{root}/")
286
286
  return [] unless File.directory?(search_directory)
287
287
 
@@ -34,7 +34,7 @@ module Kward
34
34
  rows = [editor_top_border(width)]
35
35
  rows.concat(visible_lines.each_with_index.map do |line, index|
36
36
  gutter = if index < actual_visible_count
37
- editor_line_number_gutter(@editor_state.viewport_row + index)
37
+ editor_line_number_gutter(@editor_state.viewport_row + index, line_index)
38
38
  else
39
39
  editor_blank_line_number_gutter
40
40
  end
@@ -59,7 +59,7 @@ module Kward
59
59
  rows = [editor_top_border(width)]
60
60
  rows.concat(visible_rows.map do |visual_row|
61
61
  if visual_row
62
- gutter = visual_row[:continuation] ? editor_blank_line_number_gutter : editor_line_number_gutter(visual_row[:line_index])
62
+ gutter = visual_row[:continuation] ? editor_blank_line_number_gutter : editor_line_number_gutter(visual_row[:line_index], line_index)
63
63
  rendered_line = editor_render_line(visual_row[:line], visual_row[:line_index], text_width, column_offset: visual_row[:column_offset])
64
64
  box_content_row(gutter + rendered_line, content_width)
65
65
  else
@@ -230,16 +230,16 @@ module Kward
230
230
  (column.to_i / text_width) * text_width
231
231
  end
232
232
 
233
- def editor_line_number_gutter(line_index)
234
- number = editor_display_line_number(line_index).to_s.rjust(editor_line_number_gutter_width - 3)
235
- colored("#{number} ", :dark_forest_green)
233
+ def editor_line_number_gutter(line_index, cursor_line)
234
+ number = editor_display_line_number(line_index, cursor_line).to_s.rjust(editor_line_number_gutter_width - 3)
235
+ number_color = line_index == cursor_line ? :white : :dark_forest_green
236
+ colored(number, number_color) + colored(" │ ", :dark_forest_green)
236
237
  end
237
238
 
238
- def editor_display_line_number(line_index)
239
+ def editor_display_line_number(line_index, cursor_line)
239
240
  return line_index + 1 unless current_editor_line_numbers == "relative"
240
241
  return line_index + 1 if @editor_state.readonly?
241
242
 
242
- cursor_line, = @editor_state.cursor_line_and_column
243
243
  line_index == cursor_line ? line_index + 1 : (line_index - cursor_line).abs
244
244
  end
245
245
 
@@ -257,7 +257,7 @@ module Kward
257
257
 
258
258
  def editor_display_path
259
259
  path = @editor_state.path || @editor_state.display_path
260
- Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
260
+ Pathname.new(path).relative_path_from(Pathname.new(prompt_workspace_root)).to_s
261
261
  rescue StandardError
262
262
  @editor_state.display_path || @editor_state.path
263
263
  end
@@ -793,21 +793,15 @@ module Kward
793
793
  end
794
794
 
795
795
  def cursor_line_and_column_for(offset)
796
- before_cursor = @buffer[0...offset].to_s
797
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
796
+ @text_buffer.line_and_column_for(offset)
798
797
  end
799
798
 
800
799
  def line_start_offset(line_index)
801
- values = lines
802
- line_index = [[line_index.to_i, 0].max, values.length - 1].min
803
- values.first(line_index).sum { |line| line.length + 1 }
800
+ @text_buffer.line_start_offset(line_index)
804
801
  end
805
802
 
806
803
  def line_range(line_index)
807
- start_index = line_start_offset(line_index)
808
- end_index = start_index + lines[line_index].to_s.length
809
- end_index += 1 if end_index < @buffer.length
810
- [start_index, end_index]
804
+ @text_buffer.line_range(line_index)
811
805
  end
812
806
 
813
807
  def word_range_at(offset)
@@ -11,20 +11,24 @@ module Kward
11
11
  ].freeze
12
12
  RUBY_PATTERN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|:[a-zA-Z_]\w*[!?=]?|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(RUBY_KEYWORDS)})\b)/.freeze
13
13
  MARKDOWN_PATTERN = /(`[^`\n]+`|!?\[[^\]\n]+\]\([^\)\n]+\)|(?:\*\*|__)[^\n]+?(?:\*\*|__)|(?:\*|_)[^\n]+?(?:\*|_))/.freeze
14
- HTML_PATTERN = /(<!--.*?-->|<\/?[A-Za-z][^>]*>|\b[A-Za-z_:-]+(?=\=)|"[^"]*"|'[^']*')/.freeze
14
+ HTML_TAG_START_PATTERN = /<\/?[A-Za-z][\w:-]*/.freeze
15
15
  CSS_PATTERN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[0-9a-fA-F]{3,8}\b|\.[A-Za-z_-][\w-]*|#[A-Za-z_-][\w-]*|\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms)?\b|[A-Za-z_-][\w-]*(?=\s*:)|@[A-Za-z_-][\w-]*)/.freeze
16
16
  JSON_PATTERN = /("(?:\\.|[^"\\])*"(?=\s*:)|"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/.freeze
17
17
  YAML_PATTERN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:true|false|null|yes|no|on|off)\b|\b\d+(?:\.\d+)?\b|[A-Za-z0-9_-]+(?=\s*:))/.freeze
18
18
  SQL_PATTERN = /("(?:\\.|[^"\\])*"|'(?:''|[^'])*'|--.*|\b\d+(?:\.\d+)?\b|\b(?:SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|INSERT|INTO|UPDATE|DELETE|CREATE|ALTER|DROP|TABLE|VIEW|INDEX|VALUES|SET|AND|OR|NOT|NULL|IS|AS|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|DISTINCT|UNION|ALL|CASE|WHEN|THEN|ELSE|END|PRIMARY|KEY|FOREIGN|REFERENCES|DEFAULT|TRUE|FALSE)\b)/i.freeze
19
19
  GENERIC_STRING_NUMBER_PATTERN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b)/.freeze
20
+ ERB_OPEN_PATTERN = /<%[#=]?/.freeze
21
+ ERB_CLOSE_PATTERN = /-?%>/.freeze
20
22
 
21
23
  LANGUAGE_DEFINITIONS = {
22
24
  javascript: {
23
25
  extensions: %w[.js .jsx .mjs .cjs],
26
+ block_comment: true,
24
27
  keywords: %w[async await break case catch class const continue debugger default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while with yield]
25
28
  },
26
29
  typescript: {
27
30
  extensions: %w[.ts .tsx],
31
+ block_comment: true,
28
32
  keywords: %w[abstract any as async await boolean break case catch class const constructor continue debugger declare default delete do else enum export extends false finally for from function if implements import in infer instanceof interface is keyof let module namespace never new null number object of private protected public readonly return static string super switch symbol this throw true try type typeof undefined unknown var void while with yield]
29
33
  },
30
34
  shell: {
@@ -60,34 +64,42 @@ module Kward
60
64
  },
61
65
  go: {
62
66
  extensions: %w[.go],
67
+ block_comment: true,
63
68
  keywords: %w[break case chan const continue default defer else fallthrough false for func go goto if import interface map nil package range return select struct switch true type var]
64
69
  },
65
70
  rust: {
66
71
  extensions: %w[.rs],
72
+ block_comment: true,
67
73
  keywords: %w[as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while]
68
74
  },
69
75
  java: {
70
76
  extensions: %w[.java],
77
+ block_comment: true,
71
78
  keywords: %w[abstract assert boolean break byte case catch char class const continue default do double else enum extends false final finally float for if implements import instanceof int interface long native new null package private protected public return short static strictfp super switch synchronized this throw throws transient true try void volatile while]
72
79
  },
73
80
  csharp: {
74
81
  extensions: %w[.cs],
82
+ block_comment: true,
75
83
  keywords: %w[abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var async await]
76
84
  },
77
85
  c: {
78
86
  extensions: %w[.c .h],
87
+ block_comment: true,
79
88
  keywords: %w[auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while]
80
89
  },
81
90
  cpp: {
82
91
  extensions: %w[.cc .cpp .cxx .hpp .hh .hxx],
92
+ block_comment: true,
83
93
  keywords: %w[alignas alignof and asm auto bool break case catch char char16_t char32_t class const constexpr const_cast continue decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept nullptr operator or private protected public register reinterpret_cast return short signed sizeof static static_assert static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while]
84
94
  },
85
95
  swift: {
86
96
  extensions: %w[.swift],
97
+ block_comment: true,
87
98
  keywords: %w[as associatedtype break case catch class continue default defer deinit do else enum extension false fileprivate for func guard if import in init inout internal is let nil open operator private protocol public repeat rethrows return self Self static struct subscript super switch throw throws true try typealias var where while]
88
99
  },
89
100
  kotlin: {
90
101
  extensions: %w[.kt .kts],
102
+ block_comment: true,
91
103
  keywords: %w[as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field file finally get import init param property receiver set setparam where actual abstract annotation companion const crossinline data enum expect external final infix inline inner internal lateinit noinline open operator out override private protected public reified sealed suspend tailrec vararg]
92
104
  },
93
105
  lua: {
@@ -103,6 +115,7 @@ module Kward
103
115
  JSON_EXTENSIONS = %w[.json].freeze
104
116
  YAML_EXTENSIONS = %w[.yml .yaml].freeze
105
117
  HTML_EXTENSIONS = %w[.html .htm].freeze
118
+ ERB_EXTENSIONS = %w[.erb].freeze
106
119
  CSS_EXTENSIONS = %w[.css].freeze
107
120
  SCSS_EXTENSIONS = %w[.scss].freeze
108
121
  SQL_EXTENSIONS = %w[.sql].freeze
@@ -123,6 +136,8 @@ module Kward
123
136
  editor_highlight_yaml(line)
124
137
  when :html
125
138
  editor_highlight_html(line)
139
+ when :erb
140
+ editor_highlight_erb(line, line_index)
126
141
  when :css, :scss
127
142
  editor_highlight_css(line)
128
143
  when :sql
@@ -153,6 +168,7 @@ module Kward
153
168
  return :json if JSON_EXTENSIONS.include?(extension)
154
169
  return :yaml if YAML_EXTENSIONS.include?(extension)
155
170
  return :html if HTML_EXTENSIONS.include?(extension)
171
+ return :erb if ERB_EXTENSIONS.include?(extension)
156
172
  return :scss if SCSS_EXTENSIONS.include?(extension)
157
173
  return :css if CSS_EXTENSIONS.include?(extension)
158
174
  return :sql if SQL_EXTENSIONS.include?(extension)
@@ -174,6 +190,80 @@ module Kward
174
190
  editor_highlight_ruby_code(text[0...comment_index].to_s) + colored(text[comment_index..].to_s, :gray)
175
191
  end
176
192
 
193
+ def editor_highlight_erb(line, line_index = nil)
194
+ segments, = editor_erb_segments(line.to_s, editor_erb_state_before_line(line_index))
195
+ output = +""
196
+ comment = +""
197
+ html_in_tag = false
198
+
199
+ segments.each do |kind, text|
200
+ if kind == :comment
201
+ comment << text
202
+ next
203
+ end
204
+
205
+ unless comment.empty?
206
+ output << colored(comment, :gray)
207
+ comment.clear
208
+ end
209
+ case kind
210
+ when :template
211
+ highlighted, html_in_tag = editor_highlight_html_segment(text, in_tag: html_in_tag)
212
+ output << highlighted
213
+ when :ruby
214
+ output << editor_highlight_ruby(text)
215
+ when :delimiter
216
+ output << colored(text, :cyan)
217
+ end
218
+ end
219
+ output << colored(comment, :gray) unless comment.empty?
220
+ output
221
+ end
222
+
223
+ def editor_erb_state_before_line(line_index)
224
+ return :template unless line_index && @editor_state
225
+
226
+ state = :template
227
+ @editor_state.lines.first(line_index.to_i).each do |line|
228
+ _, state = editor_erb_segments(line.to_s, state)
229
+ end
230
+ state
231
+ end
232
+
233
+ def editor_erb_segments(text, state)
234
+ segments = []
235
+ cursor = 0
236
+
237
+ while cursor < text.length
238
+ if state == :template
239
+ opening = text.match(ERB_OPEN_PATTERN, cursor)
240
+ if opening
241
+ segments << [:template, text[cursor...opening.begin(0)].to_s] unless opening.begin(0) == cursor
242
+ opening_kind = opening[0] == "<%#" ? :comment : :delimiter
243
+ segments << [opening_kind, opening[0]]
244
+ state = opening_kind == :comment ? :comment : :ruby
245
+ cursor = opening.end(0)
246
+ else
247
+ segments << [:template, text[cursor..].to_s]
248
+ break
249
+ end
250
+ else
251
+ closing = text.match(ERB_CLOSE_PATTERN, cursor)
252
+ if closing
253
+ segments << [state, text[cursor...closing.begin(0)].to_s] unless closing.begin(0) == cursor
254
+ segments << [state == :comment ? :comment : :delimiter, closing[0]]
255
+ state = :template
256
+ cursor = closing.end(0)
257
+ else
258
+ segments << [state, text[cursor..].to_s]
259
+ break
260
+ end
261
+ end
262
+ end
263
+
264
+ [segments, state]
265
+ end
266
+
177
267
  def editor_highlight_ruby_code(line)
178
268
  line.to_s.gsub(RUBY_PATTERN) do |token|
179
269
  editor_highlight_ruby_token(token)
@@ -216,8 +306,7 @@ module Kward
216
306
  return line.to_s unless definition
217
307
 
218
308
  text = line.to_s
219
- return colored(text, :gray) if editor_c_style_block_comment_line?(line_index)
220
-
309
+ return colored(text, :gray) if definition[:block_comment] && editor_c_style_block_comment_line?(line_index)
221
310
  marker = definition[:line_comment] || "//"
222
311
  comment_index = editor_comment_index(text, marker)
223
312
  return editor_highlight_generic_code(text, definition[:keywords]) unless comment_index
@@ -233,7 +322,8 @@ module Kward
233
322
  end
234
323
 
235
324
  def editor_generic_pattern(keywords)
236
- /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
325
+ @editor_generic_patterns ||= {}
326
+ @editor_generic_patterns[keywords] ||= /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
237
327
  end
238
328
 
239
329
  def editor_highlight_generic_token(token, keywords)
@@ -253,16 +343,31 @@ module Kward
253
343
  def editor_c_style_block_comment_line?(line_index)
254
344
  return false unless line_index && @editor_state
255
345
 
256
- in_comment = false
257
- @editor_state.lines.first(line_index.to_i + 1).each_with_index do |line, index|
346
+ lines = @editor_state.lines
347
+ unless @editor_block_comment_lines.equal?(lines)
348
+ @editor_block_comment_lines = lines
349
+ @editor_block_comment_states = []
350
+ @editor_block_comment_in_comment = false
351
+ end
352
+
353
+ target = line_index.to_i
354
+ return false if target.negative?
355
+ return @editor_block_comment_states[target] if target < @editor_block_comment_states.length
356
+
357
+ (@editor_block_comment_states.length..target).each do |index|
358
+ line = lines[index].to_s
258
359
  starts_block = editor_comment_index(line, "/*")
259
- ends_block = in_comment && line.include?("*/")
260
- return true if index == line_index && (in_comment || starts_block)
360
+ ends_block = @editor_block_comment_in_comment && line.include?("*/")
361
+ @editor_block_comment_states << (@editor_block_comment_in_comment || !starts_block.nil?)
261
362
 
262
- in_comment = true if starts_block && !line[starts_block..].to_s.include?("*/")
263
- in_comment = false if ends_block
363
+ if starts_block && !line[starts_block..].to_s.include?("*/")
364
+ @editor_block_comment_in_comment = true
365
+ elsif ends_block
366
+ @editor_block_comment_in_comment = false
367
+ end
264
368
  end
265
- false
369
+
370
+ @editor_block_comment_states[target]
266
371
  end
267
372
 
268
373
  def editor_comment_index(line, marker)
@@ -372,17 +477,112 @@ module Kward
372
477
  end
373
478
 
374
479
  def editor_highlight_html(line)
375
- line.to_s.gsub(HTML_PATTERN) do |token|
376
- if token.start_with?("<!--")
377
- colored(token, :gray)
378
- elsif token.start_with?("<")
379
- colored(token, :blue)
380
- elsif token.start_with?("\"", "'")
381
- colored(token, :green)
480
+ highlighted, = editor_highlight_html_segment(line.to_s)
481
+ highlighted
482
+ end
483
+
484
+ def editor_highlight_html_segment(text, in_tag: false)
485
+ output = +""
486
+ cursor = 0
487
+
488
+ while cursor < text.length
489
+ if in_tag
490
+ close_index = text.index(">", cursor)
491
+ if close_index
492
+ output << editor_highlight_html_tag_fragment(text[cursor..close_index].to_s)
493
+ cursor = close_index + 1
494
+ in_tag = false
495
+ else
496
+ output << editor_highlight_html_tag_fragment(text[cursor..].to_s)
497
+ break
498
+ end
499
+ next
500
+ end
501
+
502
+ comment_index = text.index("<!--", cursor)
503
+ tag = text.match(HTML_TAG_START_PATTERN, cursor)
504
+ next_match = [comment_index, tag&.begin(0)].compact.min
505
+ unless next_match
506
+ output << text[cursor..].to_s
507
+ break
508
+ end
509
+
510
+ output << text[cursor...next_match].to_s if next_match > cursor
511
+ if comment_index == next_match
512
+ close_index = text.index("-->", comment_index + 4)
513
+ end_index = close_index ? close_index + 3 : text.length
514
+ output << colored(text[comment_index...end_index].to_s, :gray)
515
+ cursor = end_index
382
516
  else
383
- colored(token, :cyan)
517
+ close_index = text.index(">", tag.end(0))
518
+ end_index = close_index ? close_index + 1 : text.length
519
+ output << editor_highlight_html_tag_fragment(text[tag.begin(0)...end_index].to_s)
520
+ cursor = end_index
521
+ in_tag = close_index.nil?
384
522
  end
385
523
  end
524
+
525
+ [output, in_tag]
526
+ end
527
+
528
+ def editor_highlight_html_tag_fragment(tag)
529
+ output = +""
530
+ cursor = 0
531
+ head = tag.match(/\A<\/?[A-Za-z][\w:-]*/)
532
+ if head
533
+ output << colored(head[0], :blue)
534
+ cursor = head.end(0)
535
+ end
536
+
537
+ while cursor < tag.length
538
+ rest = tag[cursor..].to_s
539
+ if rest.start_with?(">", "/>")
540
+ output << colored(rest.start_with?("/>") ? "/>" : ">", :blue)
541
+ cursor += rest.start_with?("/>") ? 2 : 1
542
+ elsif rest.match?(/\A\s/)
543
+ whitespace = rest[/\A\s+/]
544
+ output << whitespace
545
+ cursor += whitespace.length
546
+ elsif rest.start_with?("\"", "'")
547
+ quote = rest[0]
548
+ close_index = rest.index(quote, 1)
549
+ if close_index
550
+ end_index = close_index + 1
551
+ output << colored(rest[0...end_index], :green)
552
+ cursor += end_index
553
+ elsif rest.end_with?(">") && rest.length > 1
554
+ output << colored(rest[0...-1], :green)
555
+ output << colored(">", :blue)
556
+ cursor += rest.length
557
+ else
558
+ output << colored(rest, :green)
559
+ cursor += rest.length
560
+ end
561
+ elsif (attribute = rest.match(/\A([A-Za-z_:][\w:.-]*)(\s*=\s*)/))
562
+ output << colored(attribute[1], :cyan)
563
+ output << attribute[2]
564
+ cursor += attribute[0].length
565
+ value = tag[cursor..].to_s
566
+ if value.start_with?("\"", "'")
567
+ quote = value[0]
568
+ close_index = value.index(quote, 1)
569
+ end_index = close_index ? close_index + 1 : value.length
570
+ output << colored(value[0...end_index], :green)
571
+ cursor += end_index
572
+ elsif (unquoted = value.match(/\A[^\s>]+/))
573
+ output << colored(unquoted[0], :green)
574
+ cursor += unquoted[0].length
575
+ end
576
+ elsif (attribute = rest.match(/\A[A-Za-z_:][\w:.-]*/))
577
+ output << colored(attribute[0], :cyan)
578
+ cursor += attribute[0].length
579
+ else
580
+ output << rest[0]
581
+ cursor += 1
582
+ end
583
+ end
584
+
585
+ output
386
586
  end
387
587
 
388
588
  def editor_highlight_css(line)
@@ -133,11 +133,11 @@ module Kward
133
133
  end
134
134
 
135
135
  def git_project_file_paths
136
- ProjectFiles.git_paths(Dir.pwd)
136
+ ProjectFiles.git_paths(prompt_workspace_root)
137
137
  end
138
138
 
139
139
  def scanned_project_file_paths
140
- ProjectFiles.scanned_paths(Dir.pwd)
140
+ ProjectFiles.scanned_paths(prompt_workspace_root)
141
141
  end
142
142
 
143
143
  def selected_file_mention_path
@@ -84,6 +84,8 @@ module Kward
84
84
  binding_result = handle_composer_key_binding(key) if git_composing?
85
85
  return binding_result unless binding_result == false || binding_result.nil?
86
86
 
87
+ return git_move_selection(1) if key == "j" && !git_composing?
88
+ return git_move_selection(-1) if key == "k" && !git_composing?
87
89
  return git_toggle_selected_file if key == "s" && !git_composing?
88
90
 
89
91
  insert_key(key) if git_composing?
@@ -111,8 +113,10 @@ module Kward
111
113
  delete_at_cursor if git_composing?
112
114
  nil
113
115
  else
114
- if !git_composing? && code == "s".ord && (sequence[:modifiers].to_s.empty? || sequence[:modifiers].to_s == "1")
115
- return git_toggle_selected_file
116
+ unless git_composing?
117
+ return git_move_selection(1) if code == "j".ord
118
+ return git_move_selection(-1) if code == "k".ord
119
+ return git_toggle_selected_file if code == "s".ord && (sequence[:modifiers].to_s.empty? || sequence[:modifiers].to_s == "1")
116
120
  end
117
121
  return false unless git_composing?
118
122
 
@@ -265,7 +269,7 @@ module Kward
265
269
  def git_overlay_rows(width, height: screen_height)
266
270
  return [] unless @git_state
267
271
 
268
- help = git_composing? ? "Type commit message · Enter commit · Tab overlay · Esc cancel" : "↑/↓ select · Enter diff · s stage/unstage · Tab message · Esc cancel"
272
+ help = git_composing? ? "Type commit message · Enter commit · Tab overlay · Esc cancel" : "↑/↓/j/k select · Enter diff · s stage/unstage · Tab message · Esc cancel"
269
273
  lines = [overlay_text_line(help, :muted), overlay_blank_line]
270
274
  status_lines = @git_state[:status_lines]
271
275
  status_lines = ["No uncommitted changes."] if status_lines.empty?
@@ -45,7 +45,8 @@ module Kward
45
45
  @on_tick = block
46
46
  end
47
47
 
48
- # Places a character at the given canvas position with optional color.
48
+ # Stages a character at the given canvas position with optional color.
49
+ # Call {#render} after completing the frame to publish it.
49
50
  #
50
51
  # @param row [Integer] zero-based row
51
52
  # @param col [Integer] zero-based column
@@ -59,15 +60,14 @@ module Kward
59
60
  return if col.negative? || col >= @width
60
61
 
61
62
  @cells[row][col] = { char: char.to_s[0] || " ", colors: colors.flatten }
62
- @dirty = true
63
63
  end
64
64
 
65
- # Clears all canvas cells to blank.
65
+ # Stages a reset of all canvas cells to blank. Call {#render} after
66
+ # completing the frame to publish it.
66
67
  #
67
68
  # @return [void]
68
69
  def clear_frame
69
70
  @cells = Array.new(@height) { Array.new(@width) { blank_cell } }
70
- @dirty = true
71
71
  end
72
72
 
73
73
  # Marks the canvas as ready for Kward to render. Called after the plugin