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
@@ -15,13 +15,13 @@ module Kward
15
15
  end
16
16
 
17
17
  def git_paths(root, include_ignored: false)
18
- output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
18
+ output, status = Open3.capture2e("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
19
  return [] unless status.success?
20
20
 
21
21
  paths = output.lines.map(&:chomp).reject(&:empty?)
22
22
  return paths unless include_ignored
23
23
 
24
- ignored_output, ignored_status = Open3.capture2("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
24
+ ignored_output, ignored_status = Open3.capture2e("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
25
25
  return paths unless ignored_status.success?
26
26
 
27
27
  paths + ignored_output.lines.map(&:chomp).reject(&:empty?)
@@ -1,3 +1,5 @@
1
+ require_relative "../terminal_text"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Renderer for the editable composer text area.
@@ -35,45 +37,42 @@ module Kward
35
37
  end
36
38
 
37
39
  def compact_composer_layout(width)
38
- cursor_line, cursor_col = cursor_logical_position
40
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
39
41
  prefix = "#{@prompt_label} "
40
- line = input_lines[cursor_line] || ""
41
- input_width = [width - prefix.length, 1].max
42
- visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
43
- visible = line[visible_start, input_width].to_s
44
- row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
45
- [[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
42
+ prefix_width = TerminalText.width(prefix)
43
+ line = lines[cursor_line] || ""
44
+ input_width = [width - prefix_width, 1].max
45
+ layout = TerminalText.wrap(line, width: input_width, cursor: cursor_col)
46
+ visible = layout[:rows][layout[:cursor_row]].to_s
47
+ row = visible_ljust(TerminalText.truncate("#{prefix}#{visible}", width), width)
48
+ [[row], 0, [prefix_width + layout[:cursor_col], width - 1].min]
46
49
  end
47
50
 
48
51
  def input_layout(content_width)
49
- cursor_line, cursor_col = cursor_logical_position
52
+ lines, cursor_line, cursor_col = @composer.lines_and_cursor
50
53
  rows = []
51
54
  cursor_row = 0
55
+ cursor_col_in_row = 0
52
56
  rendered_row_offset = 0
53
57
 
54
- input_lines.each_with_index do |line, index|
58
+ lines.each_with_index do |line, index|
55
59
  prefix = input_prefix(index)
56
- continuation_prefix = " " * prefix.length
57
- available = [content_width - prefix.length, 1].max
58
- chunks = line.scan(/.{1,#{available}}/m)
59
- chunks = [""] if chunks.empty?
60
- if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
61
- chunks << ""
62
- end
60
+ prefix_width = TerminalText.width(prefix)
61
+ continuation_prefix = " " * prefix_width
62
+ available = [content_width - prefix_width, 1].max
63
+ layout = TerminalText.wrap(line, width: available, cursor: index == cursor_line ? cursor_col : nil)
63
64
 
64
65
  if index == cursor_line
65
- cursor_row = rendered_row_offset + (cursor_col / available)
66
+ cursor_row = rendered_row_offset + layout[:cursor_row]
67
+ cursor_col_in_row = prefix_width + layout[:cursor_col]
66
68
  end
67
69
 
68
- chunks.each_with_index do |chunk, chunk_index|
70
+ layout[:rows].each_with_index do |chunk, chunk_index|
69
71
  rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
70
72
  end
71
- rendered_row_offset += chunks.length
73
+ rendered_row_offset += layout[:rows].length
72
74
  end
73
75
 
74
- prefix = input_prefix(cursor_line)
75
- available = [content_width - prefix.length, 1].max
76
- cursor_col_in_row = prefix.length + (cursor_col % available)
77
76
  [rows, cursor_row, cursor_col_in_row]
78
77
  end
79
78
 
@@ -81,23 +80,28 @@ module Kward
81
80
  title = composer_title
82
81
  status = cached_composer_status_text
83
82
  if status
84
- gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
83
+ gap = width - 2 - TerminalText.width(ANSI.strip(title)) - TerminalText.width(ANSI.strip(status))
85
84
  if gap >= 0
86
85
  return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
87
86
  end
88
87
  end
89
88
  plain_title = ANSI.strip(title)
90
- "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
89
+ "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - TerminalText.width(plain_title) - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
91
90
  end
92
91
 
93
92
  def composer_title
94
93
  label = composer_title_label
95
94
  if @busy && @queued_count.positive?
96
- status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
95
+ status_composer_text(busy_title("#{label} · #{colored("#{@queued_count} queued", :metadata)}"))
97
96
  elsif @busy && @steered_count.to_i.positive?
98
- status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
97
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} steering"))
98
+ elsif @busy && response_arriving?
99
+ status_composer_text(busy_title("#{label} · #{colored("✦", :activity, :bold)} responding"))
99
100
  elsif @busy
100
- status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
101
+ status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} #{@busy_activity}"))
102
+ elsif @completion_status
103
+ completion = colored(@completion_status[:text], @completion_status[:style], :bold)
104
+ status_composer_text("#{label} · #{completion}")
101
105
  else
102
106
  status_composer_text(label)
103
107
  end
@@ -110,6 +114,8 @@ module Kward
110
114
  end
111
115
 
112
116
  def busy_title(text)
117
+ elapsed = busy_elapsed_text
118
+ text = "#{text} · #{colored(elapsed, :metadata)}" if elapsed
113
119
  @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
114
120
  end
115
121
 
@@ -140,7 +146,7 @@ module Kward
140
146
  label_left = 4
141
147
  @tabs.each_with_index.map do |label, index|
142
148
  text = tab_label(label, index)
143
- width = ANSI.strip(text).length
149
+ width = TerminalText.width(ANSI.strip(text))
144
150
  slot = {
145
151
  left: label_left - 2,
146
152
  label_left: label_left,
@@ -185,18 +191,25 @@ module Kward
185
191
 
186
192
  visible_offset = 0
187
193
  last_index = nil
188
- text.to_s.scan(/\e\[[0-9;:]*m|./m).each do |part|
194
+ text.to_s.scan(/\e\[[0-9;:]*m|\X/m).each do |part|
189
195
  if part.start_with?("\e")
190
196
  index = visible_offset.positive? ? last_index : left
191
197
  row[index] = row[index].to_s + part if index&.between?(0, row.length - 1)
192
198
  next
193
199
  end
194
200
 
201
+ part_width = TerminalText.width(part)
202
+ if part_width.zero?
203
+ row[last_index] = row[last_index].to_s + part if last_index
204
+ next
205
+ end
206
+
195
207
  index = left + visible_offset
196
208
  break if index >= row.length
197
209
  row[index] = row[index].to_s.sub(/\A /, "") + part unless index.negative?
210
+ 1.upto(part_width - 1) { |offset| row[index + offset] = "" if (index + offset).between?(0, row.length - 1) }
198
211
  last_index = index
199
- visible_offset += 1
212
+ visible_offset += part_width
200
213
  end
201
214
  end
202
215
 
@@ -252,19 +265,10 @@ module Kward
252
265
  [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
253
266
  end
254
267
 
255
- def input_lines
256
- lines = composer_input.split("\n", -1)
257
- lines.empty? ? [""] : lines
258
- end
259
-
260
268
  def input_prefix(_index)
261
269
  ""
262
270
  end
263
271
 
264
- def cursor_logical_position
265
- @composer.cursor_logical_position
266
- end
267
-
268
272
  def image_viewer_preview_rows(height, width: screen_width)
269
273
  available_rows = max_project_browser_rows(height, width: width)
270
274
  desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
@@ -276,7 +280,7 @@ module Kward
276
280
  preview_rows = image_viewer_preview_rows(height, width: width)
277
281
  title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
282
  rows = [
279
- colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
283
+ colored("╭", :primary_green) + title + colored("─" * [width - TerminalText.width(ANSI.strip(title)) - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
284
  image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
285
  ]
282
286
  rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
@@ -1,4 +1,6 @@
1
1
  require_relative "../text_boundary"
2
+ require_relative "../text_matcher"
3
+ require_relative "../terminal_text"
2
4
 
3
5
  # Namespace for the Kward CLI agent runtime.
4
6
  module Kward
@@ -41,6 +43,8 @@ module Kward
41
43
  @history_search_query = nil
42
44
  @history_search_draft = nil
43
45
  @history_search_index = 0
46
+ @history_search_matches_query = nil
47
+ @history_search_matches = nil
44
48
  end
45
49
 
46
50
  # Removes all pending attachments without changing text input.
@@ -76,31 +80,33 @@ module Kward
76
80
  @cursor += string.length
77
81
  end
78
82
 
79
- # Deletes one character before the cursor.
83
+ # Deletes one grapheme before the cursor.
80
84
  def delete_before_cursor
81
85
  return false if @cursor.zero?
82
86
 
83
- @input = @input[0...(@cursor - 1)] + @input[@cursor..]
84
- @cursor -= 1
87
+ previous = TerminalText.previous_grapheme_boundary(@input, @cursor)
88
+ @input = @input[0...previous] + @input[@cursor..]
89
+ @cursor = previous
85
90
  true
86
91
  end
87
92
 
88
- # Deletes one character at the cursor without moving it.
93
+ # Deletes one grapheme at the cursor without moving it.
89
94
  def delete_at_cursor
90
95
  return false unless @cursor < @input.length
91
96
 
92
- @input = @input[0...@cursor] + @input[(@cursor + 1)..]
97
+ following = TerminalText.next_grapheme_boundary(@input, @cursor)
98
+ @input = @input[0...@cursor] + @input[following..]
93
99
  true
94
100
  end
95
101
 
96
- # Moves the cursor one character left when possible.
102
+ # Moves the cursor one grapheme left when possible.
97
103
  def move_cursor_left
98
- @cursor -= 1 if @cursor.positive?
104
+ @cursor = TerminalText.previous_grapheme_boundary(@input, @cursor) if @cursor.positive?
99
105
  end
100
106
 
101
- # Moves the cursor one character right when possible.
107
+ # Moves the cursor one grapheme right when possible.
102
108
  def move_cursor_right
103
- @cursor += 1 if @cursor < @input.length
109
+ @cursor = TerminalText.next_grapheme_boundary(@input, @cursor) if @cursor < @input.length
104
110
  end
105
111
 
106
112
  # Moves the cursor to the beginning of the input buffer.
@@ -164,10 +170,14 @@ module Kward
164
170
  @cursor = @input.length
165
171
  end
166
172
 
167
- # Returns `[row, column]` for cursor placement in multi-line input.
168
- def cursor_logical_position
173
+ # Returns `[lines, row, column]` for cursor-aware multi-line layout.
174
+ def lines_and_cursor
175
+ lines = @input.split("\n", -1)
169
176
  before_cursor = @input[0...@cursor]
170
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
177
+ row = before_cursor.count("\n")
178
+ line_start = before_cursor.rindex("\n")
179
+ column = line_start ? before_cursor.length - line_start - 1 : before_cursor.length
180
+ [lines.empty? ? [""] : lines, row, column]
171
181
  end
172
182
 
173
183
  # Replaces the in-memory history list with persisted entries.
@@ -175,6 +185,7 @@ module Kward
175
185
  @history = Array(values).map(&:to_s).reject { |value| value.strip.empty? }
176
186
  reset_history_navigation
177
187
  reset_history_search
188
+ invalidate_history_search_matches
178
189
  end
179
190
 
180
191
  # Stores a submitted input unless it is blank or duplicates the previous entry.
@@ -184,6 +195,7 @@ module Kward
184
195
  return false if @history.last == value
185
196
 
186
197
  @history << value
198
+ invalidate_history_search_matches
187
199
  true
188
200
  end
189
201
 
@@ -234,9 +246,13 @@ module Kward
234
246
 
235
247
  def history_search_matches
236
248
  query = @history_search_query.to_s.downcase
237
- return @history.reverse if query.empty?
249
+ return @history_search_matches if @history_search_matches_query == query && @history_search_matches
238
250
 
239
- @history.reverse.select { |value| fuzzy_history_match?(value.downcase, query) }
251
+ pattern = TextMatcher.subsequence_pattern(query)
252
+ @history_search_matches_query = query
253
+ @history_search_matches = @history.reverse.select do |value|
254
+ TextMatcher.subsequence?(value.downcase, query, pattern)
255
+ end
240
256
  end
241
257
 
242
258
  def selected_history_search_match
@@ -274,16 +290,9 @@ module Kward
274
290
  @history_search_index = 0
275
291
  end
276
292
 
277
- def fuzzy_history_match?(value, query)
278
- query.chars.all? do |char|
279
- index = value.index(char)
280
- if index
281
- value = value[(index + 1)..].to_s
282
- true
283
- else
284
- false
285
- end
286
- end
293
+ def invalidate_history_search_matches
294
+ @history_search_matches_query = nil
295
+ @history_search_matches = nil
287
296
  end
288
297
  end
289
298
  end
@@ -145,7 +145,7 @@ module Kward
145
145
  line = @editor_state.lines[line_index].to_s
146
146
  before_cursor = line[0...column].to_s
147
147
  base_indent = line[/\A[ \t]*/].to_s
148
- language = editor_syntax_language
148
+ language = editor_effective_syntax_language(line_index)
149
149
  indent = base_indent.dup
150
150
  indent += editor_indent_unit if editor_line_opens_indent?(before_cursor, language)
151
151
  indent
@@ -179,7 +179,8 @@ module Kward
179
179
 
180
180
  def editor_expected_indent_for_line(line_index)
181
181
  line = @editor_state.lines[line_index].to_s
182
- code = editor_indent_code(line, editor_syntax_language).strip
182
+ language = editor_effective_syntax_language(line_index)
183
+ code = editor_indent_code(line, language).strip
183
184
  matching_indent = editor_matching_indent_for_line(code, line_index)
184
185
  return matching_indent if matching_indent
185
186
 
@@ -187,7 +188,7 @@ module Kward
187
188
  return "" unless previous_line
188
189
 
189
190
  indent = previous_line[:indent].dup
190
- indent += editor_indent_unit if editor_line_opens_indent?(previous_line[:code], editor_syntax_language)
191
+ indent += editor_indent_unit if editor_line_opens_indent?(previous_line[:code], previous_line[:language])
191
192
  indent
192
193
  end
193
194
 
@@ -195,18 +196,12 @@ module Kward
195
196
  return nil if code.empty?
196
197
  return editor_matching_punctuation_indent(code[0], line_index) if editor_closing_punctuation?(code[0])
197
198
 
198
- editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, editor_syntax_language)
199
+ language = editor_effective_syntax_language(line_index)
200
+ editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, language)
199
201
  end
200
202
 
201
203
  def previous_non_blank_editor_line(line_index)
202
- (line_index.to_i - 1).downto(0) do |index|
203
- line = @editor_state.lines[index].to_s
204
- code = editor_indent_code(line, editor_syntax_language).rstrip
205
- next if code.strip.empty?
206
-
207
- return { indent: line[/\A[ \t]*/].to_s, code: code }
208
- end
209
- nil
204
+ editor_previous_code_lines(line_index).last
210
205
  end
211
206
 
212
207
  def editor_update_current_line_indent(indent, preserve_content_column: false)
@@ -248,7 +243,7 @@ module Kward
248
243
  line = @editor_state.lines[line_index].to_s
249
244
  before_cursor = line[0...column].to_s
250
245
  base_indent = line[/\A[ \t]*/].to_s
251
- language = editor_syntax_language
246
+ language = editor_effective_syntax_language(line_index)
252
247
  opens_indent = editor_line_opens_indent?(before_cursor, language)
253
248
  paired_closer = editor_next_paired_closer
254
249
  return nil unless paired_closer
@@ -410,7 +405,8 @@ module Kward
410
405
  end
411
406
 
412
407
  def editor_closing_punctuation?(text)
413
- PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
408
+ line_index = @editor_state.cursor_line_and_column.first
409
+ PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_effective_syntax_language(line_index))
414
410
  end
415
411
 
416
412
  def editor_reindent_for_closing_punctuation(text)
@@ -425,7 +421,7 @@ module Kward
425
421
  line_index, column = @editor_state.cursor_line_and_column
426
422
  line = @editor_state.lines[line_index].to_s
427
423
  before_cursor = line[0...column].to_s
428
- return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
424
+ return unless editor_completed_word_closer?(before_cursor, editor_effective_syntax_language(line_index))
429
425
 
430
426
  indent = editor_matching_word_indent(line_index)
431
427
  editor_reindent_current_line(indent) if indent
@@ -477,7 +473,8 @@ module Kward
477
473
  end
478
474
 
479
475
  def editor_matching_word_indent(line_index = nil)
480
- case editor_syntax_language
476
+ line_index ||= @editor_state.cursor_line_and_column.first
477
+ case editor_effective_syntax_language(line_index)
481
478
  when :ruby
482
479
  editor_matching_keyword_indent("end", %w[end], line_index)
483
480
  when :lua
@@ -513,7 +510,7 @@ module Kward
513
510
  next if code.empty?
514
511
 
515
512
  stack.pop if editor_word_closer_line?(code, closers)
516
- stack << line[:indent] if editor_word_opener_line?(code, opener)
513
+ stack << line[:indent] if editor_word_opener_line?(code, opener, line[:language])
517
514
  end
518
515
  stack.last || ""
519
516
  end
@@ -532,11 +529,16 @@ module Kward
532
529
 
533
530
  def editor_previous_code_lines(line_index = nil)
534
531
  line_index ||= @editor_state.cursor_line_and_column.first
535
- @editor_state.lines.first(line_index).filter_map do |line|
536
- code = editor_indent_code(line, editor_syntax_language).rstrip
532
+ language = editor_effective_syntax_language(line_index)
533
+ first_index = editor_syntax_context_start(line_index)
534
+ first_index = 0 unless first_index && language != :markdown
535
+
536
+ (first_index...line_index).filter_map do |index|
537
+ line = @editor_state.lines[index].to_s
538
+ code = editor_indent_code(line, language).rstrip
537
539
  next if code.strip.empty?
538
540
 
539
- { indent: line[/\A[ \t]*/].to_s, code: code }
541
+ { indent: line[/\A[ \t]*/].to_s, code: code, language: language }
540
542
  end
541
543
  end
542
544
 
@@ -544,8 +546,8 @@ module Kward
544
546
  code.to_s.scan(/[{}\[\]()]/)
545
547
  end
546
548
 
547
- def editor_word_opener_line?(code, opener)
548
- case editor_syntax_language
549
+ def editor_word_opener_line?(code, opener, language)
550
+ case language
549
551
  when :ruby
550
552
  editor_ruby_line_opens_indent?(code)
551
553
  when :lua
@@ -1,5 +1,6 @@
1
1
  require "fileutils"
2
- require_relative "../../scratchpad_runner"
2
+ require_relative "../../scratchpad_languages"
3
+ require_relative "runner"
3
4
 
4
5
  # Namespace for the Kward CLI agent runtime.
5
6
  module Kward
@@ -243,8 +244,13 @@ module Kward
243
244
  end
244
245
 
245
246
  def close_editor
247
+ cancel_editor_runner
246
248
  disable_editor_mouse_reporting(force: true)
247
249
  restore_editor_cursor_shape_locked
250
+ @editor_runner_state = nil
251
+ @editor_runner_completion = nil
252
+ @editor_runner_output_visible = false
253
+ clear_editor_runner_selection
248
254
  @editor_text_width = nil
249
255
  @editor_save_as_active = false
250
256
  @editor_save_as_buffer = ""
@@ -261,12 +267,16 @@ module Kward
261
267
  def handle_editor_key(key)
262
268
  return if key.nil?
263
269
  return handle_editor_prompt_key(key) if @editor_prompt_active
270
+ return handle_editor_runner_output_key(key) if editor_runner_output_visible?
264
271
 
265
272
  reset_editor_word_completion unless editor_word_completion_tab_key?(key) && !editor_search_active?
266
273
  mouse_result = handle_editor_mouse_key(key)
267
274
  return mouse_result unless mouse_result == false
268
275
  return handle_editor_save_as_key(key) if @editor_save_as_active
269
276
  return handle_readonly_editor_key(key) if @editor_state&.readonly?
277
+ return cancel_editor_runner if editor_runner_running? && @editor_runner_completion && key == TerminalKeys::CTRL_C
278
+ super_copy_result = handle_editor_super_copy_key(key)
279
+ return super_copy_result unless super_copy_result == false
270
280
  return handle_vibe_key(key) if @editor_state&.vibe?
271
281
  return handle_emacs_key(key) if @editor_state&.emacs?
272
282
  return handle_modern_key(key) if @editor_state&.modern?
@@ -323,6 +333,22 @@ module Kward
323
333
  end
324
334
  end
325
335
 
336
+ def handle_editor_super_copy_key(key)
337
+ sequence = parse_csi_u_key(key)
338
+ return false unless sequence
339
+ return false unless super_modifier?(sequence[:modifier]) && ctrl_code(sequence[:code]) == 99
340
+
341
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
342
+ return editor_search_cancel if editor_search_active?
343
+ return false unless editor_selection_active?
344
+
345
+ if @editor_state.vibe? && vibe_visual_mode?
346
+ vibe_yank_visual_selection
347
+ else
348
+ copy_editor_selection
349
+ end
350
+ end
351
+
326
352
  def editor_prompt_action(instruction)
327
353
  {
328
354
  editor_prompt: {
@@ -1387,45 +1413,16 @@ module Kward
1387
1413
  true
1388
1414
  end
1389
1415
 
1390
- def run_editor_buffer
1391
- return false unless @editor_state
1392
-
1393
- language = @editor_state.language || editor_syntax_language
1394
- result = ScratchpadRunner.run(language, @editor_state.buffer)
1395
- @editor_state.replace_range(0, @editor_state.buffer.length, result.buffer)
1396
- @editor_state.status = "Ran #{language} (exit #{result.exit_status})"
1397
- true
1398
- rescue StandardError => e
1399
- @editor_state.status = "Run failed: #{e.message}" if @editor_state
1400
- false
1401
- end
1402
-
1403
1416
  def normalize_scratchpad_language(language)
1404
- case language.to_s.strip.downcase
1405
- when "", "text", "txt"
1406
- :text
1407
- when "markdown", "md"
1408
- :markdown
1409
- when "ruby", "rb"
1410
- :ruby
1411
- else
1412
- :text
1413
- end
1417
+ ScratchpadLanguages.normalize(language)
1414
1418
  end
1415
1419
 
1416
1420
  def scratchpad_display_path(language)
1417
- case language.to_sym
1418
- when :markdown
1419
- "scratchpad.md"
1420
- when :ruby
1421
- "scratchpad.rb"
1422
- else
1423
- "scratchpad.txt"
1424
- end
1421
+ ScratchpadLanguages.display_path(language)
1425
1422
  end
1426
1423
 
1427
1424
  def scratchpad_status_text(language)
1428
- runnable = language.to_sym == :ruby
1425
+ runnable = ScratchpadLanguages.runnable?(language)
1429
1426
  case current_editor_mode
1430
1427
  when "vibe"
1431
1428
  runnable ? "NORMAL · i insert · :w filename save · :q quit · :run run" : "NORMAL · i insert · :w filename save · :q quit"
@@ -136,7 +136,7 @@ module Kward
136
136
  line_index, column = @editor_state.cursor_line_and_column
137
137
  return nil unless editor_endwise_line_exists?(line_index)
138
138
 
139
- language = editor_syntax_language
139
+ language = editor_effective_syntax_language(line_index)
140
140
  definition = ENDWISE_LANGUAGES[language]
141
141
  return nil unless definition
142
142
 
@@ -190,7 +190,8 @@ module Kward
190
190
  line_index >= 0 && line_index < @editor_state.lines.length
191
191
  end
192
192
 
193
- def editor_endwise_line_opens_block?(line_index, language = editor_syntax_language)
193
+ def editor_endwise_line_opens_block?(line_index, language = nil)
194
+ language ||= editor_effective_syntax_language(line_index)
194
195
  code = editor_endwise_code_line_at(line_index, language)
195
196
  return false if editor_endwise_ignored_definition?(code, language)
196
197
 
@@ -200,7 +201,7 @@ module Kward
200
201
  end
201
202
 
202
203
  def editor_endwise_closing_keyword_for_line(line_index, column, called_with_modifier: false)
203
- language = editor_syntax_language
204
+ language = editor_effective_syntax_language(line_index)
204
205
  definition = ENDWISE_LANGUAGES[language]
205
206
  return nil unless definition
206
207
 
@@ -221,6 +222,9 @@ module Kward
221
222
  return close if @editor_state.lines.length <= scan_line + 1
222
223
 
223
224
  line_below = @editor_state.lines[scan_line + 1].to_s
225
+ next_language = editor_effective_syntax_language(scan_line + 1)
226
+ return close if next_language != language
227
+
224
228
  code_below = editor_endwise_code_line_at(scan_line + 1, language)
225
229
  closes_any_block = editor_endwise_closes_block?(code_below, language)
226
230
  closes_this_block = editor_endwise_closes_with?(code_below, close)
@@ -254,6 +258,8 @@ module Kward
254
258
  end
255
259
 
256
260
  def editor_endwise_code_line_at(line_index, language)
261
+ return "" unless editor_effective_syntax_language(line_index) == language
262
+
257
263
  definition = ENDWISE_LANGUAGES[language]
258
264
  line = @editor_state.lines[line_index].to_s
259
265
  return "" if editor_endwise_inside_block_comment?(line_index, definition.fetch(:block_comments))
@@ -263,7 +269,10 @@ module Kward
263
269
 
264
270
  def editor_endwise_inside_block_comment?(line_index, block_comments)
265
271
  active_comment = nil
266
- @editor_state.lines.first(line_index + 1).each_with_index do |line, index|
272
+ first_index = editor_syntax_context_start(line_index)
273
+ first_index = 0 unless first_index
274
+ (first_index..line_index).each do |index|
275
+ line = @editor_state.lines[index].to_s
267
276
  if active_comment
268
277
  target_line = index == line_index
269
278
  active_comment = nil if line.match?(active_comment.fetch(:end))