kward 0.78.0 → 0.80.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -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 +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -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 +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -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
@@ -153,11 +153,11 @@ module Kward
153
153
  pending_deletions << line[1..].to_s
154
154
  elsif line.start_with?("+")
155
155
  deleted = pending_deletions.shift
156
- rows << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
156
+ rows.concat(side_by_side_diff_rows(deleted && "- #{deleted}", "+ #{line[1..]}", width))
157
157
  else
158
158
  rows.concat(flush_side_by_side_deletions(pending_deletions, width))
159
159
  text = line.start_with?(" ") ? line[1..].to_s : line
160
- rows << side_by_side_diff_row(" #{text}", " #{text}", width)
160
+ rows.concat(side_by_side_diff_rows(" #{text}", " #{text}", width))
161
161
  end
162
162
  end
163
163
 
@@ -170,7 +170,17 @@ module Kward
170
170
  end
171
171
 
172
172
  def flush_side_by_side_deletions(lines, width)
173
- lines.shift(lines.length).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
173
+ lines.shift(lines.length).flat_map { |line| side_by_side_diff_rows("- #{line}", nil, width) }
174
+ end
175
+
176
+ def side_by_side_diff_rows(left, right, width)
177
+ return [side_by_side_diff_row(left, right, width)] unless current_editor_soft_wrap?
178
+
179
+ left_lines = side_by_side_diff_cell_lines(left, width)
180
+ right_lines = side_by_side_diff_cell_lines(right, width)
181
+ [left_lines.length, right_lines.length].max.times.map do |index|
182
+ side_by_side_diff_row(left_lines[index], right_lines[index], width)
183
+ end
174
184
  end
175
185
 
176
186
  def side_by_side_diff_row(left, right, width)
@@ -184,6 +194,17 @@ module Kward
184
194
  visible_truncate(value, width)
185
195
  end
186
196
 
197
+ def side_by_side_diff_cell_lines(text, width)
198
+ return [""] if text.nil?
199
+
200
+ value = text.to_s
201
+ prefix = value.start_with?("- ", "+ ", " ") ? value[0, 2] : ""
202
+ content = value.delete_prefix(prefix)
203
+ return [prefix] if content.empty?
204
+
205
+ content.chars.each_slice([width - prefix.length, 1].max).map { |characters| "#{prefix}#{characters.join}" }
206
+ end
207
+
187
208
  def side_by_side_diff_column_width
188
209
  content_width = [screen_width - 4, 1].max
189
210
  gutter_width = [[999.to_s.length, 4].max + 3, 1].max
@@ -220,6 +241,7 @@ module Kward
220
241
  @editor_text_width = nil
221
242
  @editor_save_as_active = false
222
243
  @editor_save_as_buffer = ""
244
+ @editor_save_callback = nil
223
245
  @editor_state = nil
224
246
  @prompt_label = "You>"
225
247
  self.composer_input = ""
@@ -1150,6 +1172,17 @@ module Kward
1150
1172
  return true
1151
1173
  end
1152
1174
 
1175
+ if @editor_save_callback
1176
+ result = @editor_save_callback.call(@editor_state.buffer)
1177
+ if result.to_s.empty?
1178
+ close_editor
1179
+ return true
1180
+ end
1181
+
1182
+ @editor_state.status = result.to_s
1183
+ return false
1184
+ end
1185
+
1153
1186
  if path.to_s.strip.empty? && @editor_state.path.to_s.empty?
1154
1187
  if prompt_for_path
1155
1188
  begin_editor_save_as
@@ -1268,8 +1301,8 @@ module Kward
1268
1301
  end
1269
1302
 
1270
1303
  def bind_editor_save_path(path)
1271
- full_path = File.expand_path(path.to_s.strip, Dir.pwd)
1272
- root = File.expand_path(Dir.pwd)
1304
+ full_path = File.expand_path(path.to_s.strip, prompt_workspace_root)
1305
+ root = prompt_workspace_root
1273
1306
  unless full_path == root || full_path.start_with?("#{root}/")
1274
1307
  @editor_state.status = "Cannot save outside workspace"
1275
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,12 +11,14 @@ 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: {
@@ -103,6 +105,7 @@ module Kward
103
105
  JSON_EXTENSIONS = %w[.json].freeze
104
106
  YAML_EXTENSIONS = %w[.yml .yaml].freeze
105
107
  HTML_EXTENSIONS = %w[.html .htm].freeze
108
+ ERB_EXTENSIONS = %w[.erb].freeze
106
109
  CSS_EXTENSIONS = %w[.css].freeze
107
110
  SCSS_EXTENSIONS = %w[.scss].freeze
108
111
  SQL_EXTENSIONS = %w[.sql].freeze
@@ -123,6 +126,8 @@ module Kward
123
126
  editor_highlight_yaml(line)
124
127
  when :html
125
128
  editor_highlight_html(line)
129
+ when :erb
130
+ editor_highlight_erb(line, line_index)
126
131
  when :css, :scss
127
132
  editor_highlight_css(line)
128
133
  when :sql
@@ -153,6 +158,7 @@ module Kward
153
158
  return :json if JSON_EXTENSIONS.include?(extension)
154
159
  return :yaml if YAML_EXTENSIONS.include?(extension)
155
160
  return :html if HTML_EXTENSIONS.include?(extension)
161
+ return :erb if ERB_EXTENSIONS.include?(extension)
156
162
  return :scss if SCSS_EXTENSIONS.include?(extension)
157
163
  return :css if CSS_EXTENSIONS.include?(extension)
158
164
  return :sql if SQL_EXTENSIONS.include?(extension)
@@ -174,6 +180,80 @@ module Kward
174
180
  editor_highlight_ruby_code(text[0...comment_index].to_s) + colored(text[comment_index..].to_s, :gray)
175
181
  end
176
182
 
183
+ def editor_highlight_erb(line, line_index = nil)
184
+ segments, = editor_erb_segments(line.to_s, editor_erb_state_before_line(line_index))
185
+ output = +""
186
+ comment = +""
187
+ html_in_tag = false
188
+
189
+ segments.each do |kind, text|
190
+ if kind == :comment
191
+ comment << text
192
+ next
193
+ end
194
+
195
+ unless comment.empty?
196
+ output << colored(comment, :gray)
197
+ comment.clear
198
+ end
199
+ case kind
200
+ when :template
201
+ highlighted, html_in_tag = editor_highlight_html_segment(text, in_tag: html_in_tag)
202
+ output << highlighted
203
+ when :ruby
204
+ output << editor_highlight_ruby(text)
205
+ when :delimiter
206
+ output << colored(text, :cyan)
207
+ end
208
+ end
209
+ output << colored(comment, :gray) unless comment.empty?
210
+ output
211
+ end
212
+
213
+ def editor_erb_state_before_line(line_index)
214
+ return :template unless line_index && @editor_state
215
+
216
+ state = :template
217
+ @editor_state.lines.first(line_index.to_i).each do |line|
218
+ _, state = editor_erb_segments(line.to_s, state)
219
+ end
220
+ state
221
+ end
222
+
223
+ def editor_erb_segments(text, state)
224
+ segments = []
225
+ cursor = 0
226
+
227
+ while cursor < text.length
228
+ if state == :template
229
+ opening = text.match(ERB_OPEN_PATTERN, cursor)
230
+ if opening
231
+ segments << [:template, text[cursor...opening.begin(0)].to_s] unless opening.begin(0) == cursor
232
+ opening_kind = opening[0] == "<%#" ? :comment : :delimiter
233
+ segments << [opening_kind, opening[0]]
234
+ state = opening_kind == :comment ? :comment : :ruby
235
+ cursor = opening.end(0)
236
+ else
237
+ segments << [:template, text[cursor..].to_s]
238
+ break
239
+ end
240
+ else
241
+ closing = text.match(ERB_CLOSE_PATTERN, cursor)
242
+ if closing
243
+ segments << [state, text[cursor...closing.begin(0)].to_s] unless closing.begin(0) == cursor
244
+ segments << [state == :comment ? :comment : :delimiter, closing[0]]
245
+ state = :template
246
+ cursor = closing.end(0)
247
+ else
248
+ segments << [state, text[cursor..].to_s]
249
+ break
250
+ end
251
+ end
252
+ end
253
+
254
+ [segments, state]
255
+ end
256
+
177
257
  def editor_highlight_ruby_code(line)
178
258
  line.to_s.gsub(RUBY_PATTERN) do |token|
179
259
  editor_highlight_ruby_token(token)
@@ -372,17 +452,112 @@ module Kward
372
452
  end
373
453
 
374
454
  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)
455
+ highlighted, = editor_highlight_html_segment(line.to_s)
456
+ highlighted
457
+ end
458
+
459
+ def editor_highlight_html_segment(text, in_tag: false)
460
+ output = +""
461
+ cursor = 0
462
+
463
+ while cursor < text.length
464
+ if in_tag
465
+ close_index = text.index(">", cursor)
466
+ if close_index
467
+ output << editor_highlight_html_tag_fragment(text[cursor..close_index].to_s)
468
+ cursor = close_index + 1
469
+ in_tag = false
470
+ else
471
+ output << editor_highlight_html_tag_fragment(text[cursor..].to_s)
472
+ break
473
+ end
474
+ next
475
+ end
476
+
477
+ comment_index = text.index("<!--", cursor)
478
+ tag = text.match(HTML_TAG_START_PATTERN, cursor)
479
+ next_match = [comment_index, tag&.begin(0)].compact.min
480
+ unless next_match
481
+ output << text[cursor..].to_s
482
+ break
483
+ end
484
+
485
+ output << text[cursor...next_match].to_s if next_match > cursor
486
+ if comment_index == next_match
487
+ close_index = text.index("-->", comment_index + 4)
488
+ end_index = close_index ? close_index + 3 : text.length
489
+ output << colored(text[comment_index...end_index].to_s, :gray)
490
+ cursor = end_index
382
491
  else
383
- colored(token, :cyan)
492
+ close_index = text.index(">", tag.end(0))
493
+ end_index = close_index ? close_index + 1 : text.length
494
+ output << editor_highlight_html_tag_fragment(text[tag.begin(0)...end_index].to_s)
495
+ cursor = end_index
496
+ in_tag = close_index.nil?
384
497
  end
385
498
  end
499
+
500
+ [output, in_tag]
501
+ end
502
+
503
+ def editor_highlight_html_tag_fragment(tag)
504
+ output = +""
505
+ cursor = 0
506
+ head = tag.match(/\A<\/?[A-Za-z][\w:-]*/)
507
+ if head
508
+ output << colored(head[0], :blue)
509
+ cursor = head.end(0)
510
+ end
511
+
512
+ while cursor < tag.length
513
+ rest = tag[cursor..].to_s
514
+ if rest.start_with?(">", "/>")
515
+ output << colored(rest.start_with?("/>") ? "/>" : ">", :blue)
516
+ cursor += rest.start_with?("/>") ? 2 : 1
517
+ elsif rest.match?(/\A\s/)
518
+ whitespace = rest[/\A\s+/]
519
+ output << whitespace
520
+ cursor += whitespace.length
521
+ elsif rest.start_with?("\"", "'")
522
+ quote = rest[0]
523
+ close_index = rest.index(quote, 1)
524
+ if close_index
525
+ end_index = close_index + 1
526
+ output << colored(rest[0...end_index], :green)
527
+ cursor += end_index
528
+ elsif rest.end_with?(">") && rest.length > 1
529
+ output << colored(rest[0...-1], :green)
530
+ output << colored(">", :blue)
531
+ cursor += rest.length
532
+ else
533
+ output << colored(rest, :green)
534
+ cursor += rest.length
535
+ end
536
+ elsif (attribute = rest.match(/\A([A-Za-z_:][\w:.-]*)(\s*=\s*)/))
537
+ output << colored(attribute[1], :cyan)
538
+ output << attribute[2]
539
+ cursor += attribute[0].length
540
+ value = tag[cursor..].to_s
541
+ if value.start_with?("\"", "'")
542
+ quote = value[0]
543
+ close_index = value.index(quote, 1)
544
+ end_index = close_index ? close_index + 1 : value.length
545
+ output << colored(value[0...end_index], :green)
546
+ cursor += end_index
547
+ elsif (unquoted = value.match(/\A[^\s>]+/))
548
+ output << colored(unquoted[0], :green)
549
+ cursor += unquoted[0].length
550
+ end
551
+ elsif (attribute = rest.match(/\A[A-Za-z_:][\w:.-]*/))
552
+ output << colored(attribute[0], :cyan)
553
+ cursor += attribute[0].length
554
+ else
555
+ output << rest[0]
556
+ cursor += 1
557
+ end
558
+ end
559
+
560
+ output
386
561
  end
387
562
 
388
563
  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
@@ -55,6 +55,9 @@ module Kward
55
55
  controller = @interactive_state[:controller]
56
56
  return false if controller.exited?
57
57
 
58
+ return true if controller.dirty?
59
+ return false unless controller.tickable?
60
+
58
61
  now = monotonic_now
59
62
  interval = 1.0 / controller.fps
60
63
  elapsed = now - @last_interactive_tick
@@ -64,7 +67,7 @@ module Kward
64
67
  @last_interactive_tick += steps * interval
65
68
  result = controller.invoke_tick
66
69
  controller.exit if result == :exit
67
- true
70
+ controller.dirty?
68
71
  end
69
72
  end
70
73
  end
@@ -7,8 +7,8 @@ module Kward
7
7
  module InteractiveState
8
8
  private
9
9
 
10
- def interactive_canvas_width
11
- [screen_width - 6, 1].max
10
+ def interactive_canvas_width(width = screen_width)
11
+ [width - 6, 1].max
12
12
  end
13
13
 
14
14
  def route_interactive_key(key)
@@ -125,6 +125,14 @@ module Kward
125
125
  insert_selected_project_browser_mention
126
126
  elsif text == "/" && !project_browser_search_active?
127
127
  activate_project_browser_search
128
+ elsif text == "j" && !project_browser_search_active?
129
+ select_next_project_browser_row
130
+ elsif text == "k" && !project_browser_search_active?
131
+ select_previous_project_browser_row
132
+ elsif text == "h" && !project_browser_search_active?
133
+ collapse_selected_project_browser_row
134
+ elsif text == "l" && !project_browser_search_active?
135
+ expand_selected_project_browser_row
128
136
  elsif project_browser_search_active?
129
137
  project_browser_append_search(text)
130
138
  else
@@ -148,6 +156,14 @@ module Kward
148
156
  toggle_project_browser_search
149
157
  when "/"
150
158
  activate_project_browser_search
159
+ when "j"
160
+ project_browser_search_active? ? project_browser_append_search(key) : select_next_project_browser_row
161
+ when "k"
162
+ project_browser_search_active? ? project_browser_append_search(key) : select_previous_project_browser_row
163
+ when "h"
164
+ project_browser_search_active? ? project_browser_append_search(key) : collapse_selected_project_browser_row
165
+ when "l"
166
+ project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
151
167
  else
152
168
  project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
153
169
  end
@@ -319,7 +335,7 @@ module Kward
319
335
  if rows.empty?
320
336
  lines << overlay_text_line(project_browser_empty_message, :muted)
321
337
  else
322
- visible = visible_project_browser_rows(rows, height: height)
338
+ visible = visible_project_browser_rows(rows, height: height, width: width)
323
339
  visible[:rows].each_with_index do |row, offset|
324
340
  index = visible[:start] + offset
325
341
  lines << overlay_choice_line(project_browser_row_text(row), selected: index == @project_browser_state[:selection_index])
@@ -476,7 +492,7 @@ module Kward
476
492
  end
477
493
 
478
494
  def project_browser_workspace_root
479
- ConfigFiles.canonical_workspace_root(Dir.pwd)
495
+ ConfigFiles.canonical_workspace_root(prompt_workspace_root)
480
496
  end
481
497
 
482
498
  def restored_project_browser_expanded_paths(paths, saved_state)
@@ -536,14 +552,17 @@ module Kward
536
552
  @project_browser_state[:selection_index] = [[@project_browser_state[:selection_index], 0].max, rows.length - 1].min unless rows.empty?
537
553
  end
538
554
 
539
- def visible_project_browser_rows(rows, height: screen_height)
540
- max_rows = max_project_browser_rows(height)
555
+ def visible_project_browser_rows(rows, height: screen_height, width: screen_width)
556
+ max_rows = max_project_browser_rows(height, width: width)
541
557
  start = centered_list_window_start(@project_browser_state[:selection_index], rows.length, max_rows)
542
558
  { start: start, rows: rows[start, max_rows] || [] }
543
559
  end
544
560
 
545
- def max_project_browser_rows(height)
546
- [[height - 8, 4].max, 20].min
561
+ def max_project_browser_rows(height, width: screen_width)
562
+ composer_bottom_rows = @tabs.empty? ? 1 : tab_border_rows(width).length
563
+ footer_rows = footer_text.empty? ? 0 : 1
564
+ attachment_rows = attachment_badge_texts.length
565
+ [height - 7 - composer_bottom_rows - footer_rows - attachment_rows, 4].max
547
566
  end
548
567
 
549
568
  def default_project_browser_expanded_paths(paths)