kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,321 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Endwise-style closing keyword insertion for the built-in editor.
6
+ module EditorEndwise
7
+ ENDWISE_LINE_PARSE_LIMIT = 100_000
8
+ ENDWISE_SINGLE_LINE_DEFINITION = /;\s*end[\s;]*\z/.freeze
9
+ ENDWISE_ENDLESS_DEFINITION = /\A\s*?def\s+[^\s(]+\s*(?:\(.*\))?\s*=/.freeze
10
+
11
+ ENDWISE_LANGUAGES = {
12
+ ruby: {
13
+ line_comments: ["#"],
14
+ block_comments: [{ start: /\A\s*=begin\b/, end: /\A\s*=end\b/ }],
15
+ close_pattern: /\Aend\b/,
16
+ openings: [
17
+ { pattern: /\A\s*?if(\s|\()/, close: "end" },
18
+ { pattern: /\A\s*?unless(\s|\()/, close: "end" },
19
+ { pattern: /\A\s*?while(\s|\()/, close: "end" },
20
+ { pattern: /\A\s*?for(\s|\()/, close: "end" },
21
+ { pattern: /\s?do(\s?\z|\s\|.*\|\s?\z)/, close: "end" },
22
+ { pattern: /\A\s*?def\s/, close: "end" },
23
+ { pattern: /\A\s*?class\s/, close: "end" },
24
+ { pattern: /\A\s*?module\s/, close: "end" },
25
+ { pattern: /\A\s*?case(\s|\()/, close: "end" },
26
+ { pattern: /\A\s*?begin\s/, close: "end" },
27
+ { pattern: /\A\s*?until(\s|\()/, close: "end" }
28
+ ]
29
+ },
30
+ crystal: {
31
+ line_comments: ["#"],
32
+ block_comments: [],
33
+ close_pattern: /\Aend\b/,
34
+ openings: [
35
+ { pattern: /\A\s*?if(\s|\()/, close: "end" },
36
+ { pattern: /\A\s*?unless(\s|\()/, close: "end" },
37
+ { pattern: /\A\s*?while(\s|\()/, close: "end" },
38
+ { pattern: /\A\s*?for(\s|\()/, close: "end" },
39
+ { pattern: /\s?do(\s?\z|\s\|.*\|\s?\z)/, close: "end" },
40
+ { pattern: /\A\s*?enum\s/, close: "end" },
41
+ { pattern: /\A\s*?struct\s/, close: "end" },
42
+ { pattern: /\A\s*?macro\s/, close: "end" },
43
+ { pattern: /\A\s*?union\s/, close: "end" },
44
+ { pattern: /\A\s*?lib\s/, close: "end" },
45
+ { pattern: /\A\s*?annotation\s/, close: "end" },
46
+ { pattern: /\A\s*?def\s/, close: "end" },
47
+ { pattern: /\A\s*?class\s/, close: "end" },
48
+ { pattern: /\A\s*?module\s/, close: "end" },
49
+ { pattern: /\A\s*?case(\s|\()/, close: "end" },
50
+ { pattern: /\A\s*?begin\s/, close: "end" },
51
+ { pattern: /\A\s*?until(\s|\()/, close: "end" }
52
+ ]
53
+ },
54
+ elixir: {
55
+ line_comments: ["#"],
56
+ block_comments: [],
57
+ close_pattern: /\Aend\b/,
58
+ openings: [
59
+ { pattern: /\bdo\s*\z/, close: "end" },
60
+ { pattern: /\A\s*fn\s*\z/, close: "end" },
61
+ { pattern: /\bfn\b.*->\s*\z/, close: "end" }
62
+ ]
63
+ },
64
+ julia: {
65
+ line_comments: ["#"],
66
+ block_comments: [],
67
+ close_pattern: /\Aend\b/,
68
+ openings: [
69
+ { pattern: /\A\s*begin\s*\z/, close: "end" },
70
+ { pattern: /\A\s*if\b/, close: "end" },
71
+ { pattern: /\A\s*while\b/, close: "end" },
72
+ { pattern: /\A\s*for\b/, close: "end" },
73
+ { pattern: /\A\s*try\s*\z/, close: "end" },
74
+ { pattern: /\A\s*let(?:\s|\z)/, close: "end" },
75
+ { pattern: /\A\s*quote\s*\z/, close: "end" },
76
+ { pattern: /\A\s*function\b/, close: "end" },
77
+ { pattern: /\A\s*macro\b/, close: "end" },
78
+ { pattern: /\A\s*module\b/, close: "end" },
79
+ { pattern: /\A\s*baremodule\b/, close: "end" },
80
+ { pattern: /\A\s*(?:mutable\s+)?struct\b/, close: "end" },
81
+ { pattern: /\A\s*abstract\s+type\b/, close: "end" },
82
+ { pattern: /\A\s*primitive\s+type\b/, close: "end" },
83
+ { pattern: /\bdo(?:\s+.*)?\s*\z/, close: "end" }
84
+ ]
85
+ },
86
+ lua: {
87
+ line_comments: ["--"],
88
+ block_comments: [{ start: /\A\s*--\[\[/, end: /\]\]/ }],
89
+ close_pattern: /\Aend\b/,
90
+ openings: [
91
+ { pattern: /\A\s*do\s*\z/, close: "end" },
92
+ { pattern: /\A\s*while\b.*\bdo\s*\z/, close: "end" },
93
+ { pattern: /\A\s*if\b.*\bthen\s*\z/, close: "end" },
94
+ { pattern: /\A\s*for\b.*\bdo\s*\z/, close: "end" },
95
+ { pattern: /\A\s*(?:local\s+)?function\b.*\)\s*\z/, close: "end" }
96
+ ]
97
+ },
98
+ makefile: {
99
+ line_comments: ["#"],
100
+ block_comments: [],
101
+ close_pattern: /\Aendif\b/,
102
+ openings: [
103
+ { pattern: /\A\s*if(?:eq|neq)\b/, close: "endif" },
104
+ { pattern: /\A\s*ifn?def\b/, close: "endif" }
105
+ ]
106
+ },
107
+ shell: {
108
+ line_comments: ["#"],
109
+ block_comments: [],
110
+ close_pattern: /\A(?:fi|done|esac)\b/,
111
+ openings: [
112
+ { pattern: /\bthen\s*\z/, close: "fi" },
113
+ { pattern: /\A\s*case\b/, close: "esac" },
114
+ { pattern: /\bdo\s*\z/, close: "done" }
115
+ ]
116
+ }
117
+ }.freeze
118
+
119
+ private
120
+
121
+ def editor_insert_endwise_newline
122
+ plan = editor_endwise_plan(called_with_modifier: false)
123
+ return false unless plan
124
+
125
+ editor_apply_endwise_plan(plan)
126
+ end
127
+
128
+ def editor_insert_endwise_modifier_newline
129
+ plan = editor_endwise_plan(called_with_modifier: true) || editor_endwise_plain_modifier_plan
130
+ editor_apply_endwise_plan(plan)
131
+ end
132
+
133
+ def editor_endwise_plan(called_with_modifier:)
134
+ return nil unless current_editor_auto_indent?
135
+
136
+ line_index, column = @editor_state.cursor_line_and_column
137
+ return nil unless editor_endwise_line_exists?(line_index)
138
+
139
+ language = editor_syntax_language
140
+ definition = ENDWISE_LANGUAGES[language]
141
+ return nil unless definition
142
+
143
+ line = @editor_state.lines[line_index].to_s
144
+ line_length = line.length
145
+ return nil if !called_with_modifier && line_length > column
146
+
147
+ closing_indent = editor_endwise_indentation_for(line)
148
+ inner_indent = closing_indent + editor_indent_unit
149
+ close = editor_endwise_closing_keyword_for_line(line_index, column, called_with_modifier: called_with_modifier)
150
+ target_offset = @editor_state.line_start_offset(line_index) + line_length
151
+
152
+ if close
153
+ return {
154
+ cursor_column: inner_indent.length,
155
+ cursor_line: line_index + 1,
156
+ offset: target_offset,
157
+ text: "\n#{inner_indent}\n#{closing_indent}#{close}"
158
+ }
159
+ end
160
+
161
+ return nil unless called_with_modifier && editor_endwise_line_opens_block?(line_index, language)
162
+
163
+ {
164
+ cursor_column: inner_indent.length,
165
+ cursor_line: line_index + 1,
166
+ offset: target_offset,
167
+ text: "\n#{inner_indent}"
168
+ }
169
+ end
170
+
171
+ def editor_endwise_plain_modifier_plan
172
+ line_index, = @editor_state.cursor_line_and_column
173
+ line = @editor_state.lines[line_index].to_s
174
+ {
175
+ cursor_column: 0,
176
+ cursor_line: line_index + 1,
177
+ offset: @editor_state.line_start_offset(line_index) + line.length,
178
+ text: "\n"
179
+ }
180
+ end
181
+
182
+ def editor_apply_endwise_plan(plan)
183
+ @editor_state.cursor = plan.fetch(:offset)
184
+ @editor_state.insert(plan.fetch(:text))
185
+ @editor_state.set_cursor_line_and_column(plan.fetch(:cursor_line), plan.fetch(:cursor_column))
186
+ true
187
+ end
188
+
189
+ def editor_endwise_line_exists?(line_index)
190
+ line_index >= 0 && line_index < @editor_state.lines.length
191
+ end
192
+
193
+ def editor_endwise_line_opens_block?(line_index, language = editor_syntax_language)
194
+ code = editor_endwise_code_line_at(line_index, language)
195
+ return false if editor_endwise_ignored_definition?(code, language)
196
+
197
+ ENDWISE_LANGUAGES.fetch(language).fetch(:openings).any? do |opening|
198
+ code.match?(opening.fetch(:pattern))
199
+ end
200
+ end
201
+
202
+ def editor_endwise_closing_keyword_for_line(line_index, column, called_with_modifier: false)
203
+ language = editor_syntax_language
204
+ definition = ENDWISE_LANGUAGES[language]
205
+ return nil unless definition
206
+
207
+ openings = definition.fetch(:openings)
208
+ line = @editor_state.lines[line_index].to_s
209
+ code = editor_endwise_code_line_at(line_index, language)
210
+ current_indent = editor_endwise_indentation_for(line)
211
+
212
+ return nil if !called_with_modifier && line.length > column
213
+ return nil if editor_endwise_ignored_definition?(code, language)
214
+
215
+ openings.each do |opening|
216
+ next unless code.match?(opening.fetch(:pattern))
217
+
218
+ close = opening.fetch(:close)
219
+ stack_count = 0
220
+ (line_index..(line_index + ENDWISE_LINE_PARSE_LIMIT)).each do |scan_line|
221
+ return close if @editor_state.lines.length <= scan_line + 1
222
+
223
+ line_below = @editor_state.lines[scan_line + 1].to_s
224
+ code_below = editor_endwise_code_line_at(scan_line + 1, language)
225
+ closes_any_block = editor_endwise_closes_block?(code_below, language)
226
+ closes_this_block = editor_endwise_closes_with?(code_below, close)
227
+
228
+ if current_indent.length > editor_endwise_indentation_for(line_below).length && closes_any_block
229
+ return close
230
+ end
231
+
232
+ next unless current_indent == editor_endwise_indentation_for(line_below)
233
+
234
+ if openings.any? { |inner_opening| code_below.match?(inner_opening.fetch(:pattern)) }
235
+ stack_count += 1
236
+ end
237
+
238
+ if closes_any_block && stack_count.positive?
239
+ stack_count -= 1
240
+ elsif closes_this_block
241
+ return nil
242
+ end
243
+ end
244
+ end
245
+
246
+ nil
247
+ end
248
+
249
+ def editor_endwise_ignored_definition?(code, language)
250
+ return true if code.match?(ENDWISE_SINGLE_LINE_DEFINITION)
251
+ return true if %i[ruby crystal].include?(language) && code.match?(ENDWISE_ENDLESS_DEFINITION)
252
+
253
+ false
254
+ end
255
+
256
+ def editor_endwise_code_line_at(line_index, language)
257
+ definition = ENDWISE_LANGUAGES[language]
258
+ line = @editor_state.lines[line_index].to_s
259
+ return "" if editor_endwise_inside_block_comment?(line_index, definition.fetch(:block_comments))
260
+
261
+ editor_endwise_strip_line_comment(line, definition.fetch(:line_comments))
262
+ end
263
+
264
+ def editor_endwise_inside_block_comment?(line_index, block_comments)
265
+ active_comment = nil
266
+ @editor_state.lines.first(line_index + 1).each_with_index do |line, index|
267
+ if active_comment
268
+ target_line = index == line_index
269
+ active_comment = nil if line.match?(active_comment.fetch(:end))
270
+ return true if target_line
271
+
272
+ next
273
+ end
274
+
275
+ block_comments.each do |block_comment|
276
+ start_match = line.match(block_comment.fetch(:start))
277
+ next unless start_match
278
+
279
+ rest = line[(start_match.begin(0) + start_match[0].length)..].to_s
280
+ if rest.match?(block_comment.fetch(:end))
281
+ return true if index == line_index
282
+
283
+ next
284
+ end
285
+
286
+ active_comment = block_comment
287
+ return true if index == line_index
288
+
289
+ break
290
+ end
291
+ end
292
+
293
+ false
294
+ end
295
+
296
+ def editor_endwise_strip_line_comment(line, line_comments)
297
+ comment_index = line_comments.filter_map do |comment|
298
+ index = line.index(comment)
299
+ index unless index.nil?
300
+ end.min
301
+
302
+ comment_index ? line[0...comment_index].to_s : line
303
+ end
304
+
305
+ def editor_endwise_indentation_for(line)
306
+ trimmed = line.to_s.strip
307
+ return line.to_s if trimmed.empty?
308
+
309
+ line.to_s[0...line.to_s.index(trimmed)].to_s
310
+ end
311
+
312
+ def editor_endwise_closes_block?(code, language)
313
+ code.to_s.strip.match?(ENDWISE_LANGUAGES.fetch(language).fetch(:close_pattern))
314
+ end
315
+
316
+ def editor_endwise_closes_with?(code, close)
317
+ code.to_s.strip.match?(/\A#{Regexp.escape(close)}\b/)
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,40 @@
1
+ require "digest"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Interactive terminal UI used by the CLI frontend.
6
+ class PromptInterface
7
+ # Tracks the on-disk identity and original content for an editor buffer.
8
+ class EditorFileMarker
9
+ attr_reader :content, :digest, :mtime, :size
10
+
11
+ def initialize(path:, content:, new_file: false)
12
+ @path = path.to_s
13
+ @content = content.to_s
14
+ @digest = Digest::SHA256.hexdigest(@content)
15
+ refresh unless new_file
16
+ end
17
+
18
+ def refresh(content = @content)
19
+ @content = content.to_s
20
+ @digest = Digest::SHA256.hexdigest(@content)
21
+ stat = File.stat(@path)
22
+ @mtime = stat.mtime
23
+ @size = stat.size
24
+ rescue StandardError
25
+ @mtime = nil
26
+ @size = nil
27
+ end
28
+
29
+ def changed_on_disk?(new_file: false)
30
+ return false if new_file && !File.exist?(@path)
31
+ return true if new_file && File.exist?(@path)
32
+ return true unless File.exist?(@path)
33
+
34
+ File.read(@path) != @content
35
+ rescue StandardError
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Indentation-based navigation over editor lines.
6
+ class EditorIndentNavigation
7
+ def initialize(lines)
8
+ @lines = lines
9
+ end
10
+
11
+ def indentation_level_for_line(line_index)
12
+ @lines[line_index].to_s.index(/\S/) || 0
13
+ end
14
+
15
+ def empty_line?(line_index)
16
+ @lines[line_index].to_s.strip.empty?
17
+ end
18
+
19
+ def next_line(current_line, current_indentation)
20
+ end_line = @lines.length - 1
21
+ return nil if current_line == end_line
22
+
23
+ next_line = current_line + 1
24
+ jumping_over_space = indentation_level_for_line(next_line) != current_indentation || empty_line?(next_line)
25
+
26
+ (next_line..end_line).each do |line_index|
27
+ indentation = indentation_level_for_line(line_index)
28
+ if jumping_over_space && indentation == current_indentation && !empty_line?(line_index)
29
+ return line_index
30
+ elsif !jumping_over_space && (indentation != current_indentation || empty_line?(line_index))
31
+ return line_index - 1
32
+ elsif !jumping_over_space && indentation == current_indentation && line_index == end_line
33
+ return line_index
34
+ end
35
+ end
36
+
37
+ nil
38
+ end
39
+
40
+ def previous_line(current_line, current_indentation)
41
+ return nil if current_line.zero?
42
+
43
+ previous_line = current_line - 1
44
+ jumping_over_space = indentation_level_for_line(previous_line) != current_indentation || empty_line?(previous_line)
45
+
46
+ previous_line.downto(0) do |line_index|
47
+ indentation = indentation_level_for_line(line_index)
48
+ if jumping_over_space && indentation == current_indentation && !empty_line?(line_index)
49
+ return line_index
50
+ elsif !jumping_over_space && (indentation != current_indentation || empty_line?(line_index))
51
+ return line_index + 1
52
+ elsif !jumping_over_space && indentation == current_indentation && line_index.zero?
53
+ return line_index
54
+ end
55
+ end
56
+
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,78 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Kill buffer, kill ring, and yank-pop bookkeeping for editor buffers.
6
+ class EditorKillRing
7
+ attr_reader :kill_buffer, :kill_ring, :last_yank_range, :last_yank_index
8
+
9
+ def initialize(kill_buffer: "", kill_ring: [], last_yank_range: nil, last_yank_index: nil)
10
+ @kill_buffer = kill_buffer.to_s
11
+ @kill_ring = kill_ring
12
+ @last_yank_range = last_yank_range
13
+ @last_yank_index = last_yank_index
14
+ end
15
+
16
+ def kill_buffer=(text)
17
+ @kill_buffer = text.to_s
18
+ clear_last_yank
19
+ end
20
+
21
+ def kill_ring=(values)
22
+ @kill_ring = values.to_a
23
+ end
24
+
25
+ def last_yank_range=(value)
26
+ @last_yank_range = value
27
+ end
28
+
29
+ def last_yank_index=(value)
30
+ @last_yank_index = value
31
+ end
32
+
33
+ def push(text)
34
+ text = text.to_s
35
+ return false if text.empty?
36
+
37
+ @kill_buffer = text
38
+ @kill_ring.unshift(text)
39
+ @kill_ring.uniq!
40
+ @kill_ring = @kill_ring.first(30)
41
+ clear_last_yank
42
+ true
43
+ end
44
+
45
+ def first_yank
46
+ text = @kill_ring.first.to_s
47
+ return nil if text.empty?
48
+
49
+ text
50
+ end
51
+
52
+ def record_yank(start_index, end_index)
53
+ @last_yank_range = [start_index, end_index]
54
+ @last_yank_index = 0
55
+ end
56
+
57
+ def next_yank_pop
58
+ return nil unless @last_yank_range && @last_yank_index
59
+ return nil if @kill_ring.length < 2
60
+
61
+ @last_yank_index = (@last_yank_index + 1) % @kill_ring.length
62
+ {
63
+ text: @kill_ring[@last_yank_index],
64
+ range: @last_yank_range
65
+ }
66
+ end
67
+
68
+ def record_yank_pop(start_index, end_index)
69
+ @last_yank_range = [start_index, end_index]
70
+ end
71
+
72
+ def clear_last_yank
73
+ @last_yank_range = nil
74
+ @last_yank_index = nil
75
+ end
76
+ end
77
+ end
78
+ end