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,243 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Renderer for the built-in composer file editor.
6
+ module EditorRenderer
7
+ private
8
+
9
+ def editor_layout(width, height = screen_height)
10
+ content_width = [width - 4, 1].max
11
+ visible_count = editor_visible_line_count(height: height, width: width)
12
+ line_index, column = @editor_state.cursor_line_and_column
13
+ gutter_width = editor_line_number_gutter_width
14
+ text_width = editor_text_width(content_width, gutter_width)
15
+ sync_editor_wrap_state(text_width)
16
+
17
+ if current_editor_soft_wrap?
18
+ editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width)
19
+ else
20
+ editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width)
21
+ end
22
+ end
23
+
24
+ def editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width)
25
+ @editor_state.viewport_row = [[@editor_state.viewport_row, line_index - visible_count + 1].max, line_index].min
26
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
27
+ @editor_state.viewport_column = [[@editor_state.viewport_column.to_i, column - text_width + 1].max, column].min
28
+ @editor_state.viewport_column = [@editor_state.viewport_column, 0].max
29
+ editor_lines = @editor_state.lines
30
+ visible_lines = editor_lines[@editor_state.viewport_row, visible_count] || []
31
+ actual_visible_count = visible_lines.length
32
+ visible_lines << "" while visible_lines.length < visible_count
33
+ gutter_width = editor_line_number_gutter_width
34
+ rows = [editor_top_border(width)]
35
+ rows.concat(visible_lines.each_with_index.map do |line, index|
36
+ gutter = if index < actual_visible_count
37
+ editor_line_number_gutter(@editor_state.viewport_row + index)
38
+ else
39
+ editor_blank_line_number_gutter
40
+ end
41
+ rendered_line = editor_render_line(line, @editor_state.viewport_row + index, text_width, column_offset: @editor_state.viewport_column)
42
+ row = gutter + rendered_line
43
+ box_content_row(row, content_width)
44
+ end)
45
+ rows << footer_row(content_width, editor_status_text)
46
+ rows.concat(editor_bottom_rows(width))
47
+ cursor_row = 1 + line_index - @editor_state.viewport_row
48
+ cursor_col = 2 + gutter_width + [[column - @editor_state.viewport_column, 0].max, text_width - 1].min
49
+ [rows, cursor_row, cursor_col]
50
+ end
51
+
52
+ def editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width)
53
+ visual_rows = editor_visual_rows(text_width)
54
+ cursor_visual_row = editor_visual_row_for(line_index, column, text_width)
55
+ @editor_state.viewport_row = [[@editor_state.viewport_row, cursor_visual_row - visible_count + 1].max, cursor_visual_row].min
56
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
57
+ visible_rows = visual_rows[@editor_state.viewport_row, visible_count] || []
58
+ visible_rows << nil while visible_rows.length < visible_count
59
+ rows = [editor_top_border(width)]
60
+ rows.concat(visible_rows.map do |visual_row|
61
+ if visual_row
62
+ gutter = visual_row[:continuation] ? editor_blank_line_number_gutter : editor_line_number_gutter(visual_row[:line_index])
63
+ rendered_line = editor_render_line(visual_row[:line], visual_row[:line_index], text_width, column_offset: visual_row[:column_offset])
64
+ box_content_row(gutter + rendered_line, content_width)
65
+ else
66
+ box_content_row(editor_blank_line_number_gutter, content_width)
67
+ end
68
+ end)
69
+ rows << footer_row(content_width, editor_status_text)
70
+ rows.concat(editor_bottom_rows(width))
71
+ line_start = editor_visual_row_start_column(line_index, column, text_width)
72
+ cursor_row = 1 + cursor_visual_row - @editor_state.viewport_row
73
+ cursor_col = 2 + editor_line_number_gutter_width + [[column - line_start, 0].max, text_width - 1].min
74
+ [rows, cursor_row, cursor_col]
75
+ end
76
+
77
+ def editor_visible_line_count(height: screen_height, width: screen_width)
78
+ visible_count = [[height - 3 - editor_bottom_rows(width).length, 1].max, 1].max
79
+ visible_count = [visible_count, height - 4].min if height > 4
80
+ [visible_count, 1].max
81
+ end
82
+
83
+ def editor_bottom_rows(width)
84
+ @tabs.empty? ? [bottom_border(width)] : tab_border_rows(width)
85
+ end
86
+
87
+ def editor_render_line(line, line_index, text_width, column_offset: 0)
88
+ visible = line.to_s[column_offset.to_i, text_width].to_s
89
+ rendered = editor_render_visible_line(visible, line_index)
90
+ line_start = @editor_state.line_start_offset(line_index)
91
+ rendered = editor_overlay_line_selections(rendered, line_start, column_offset, visible.length)
92
+ editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible.length, text_width)
93
+ end
94
+
95
+ def editor_overlay_line_selections(rendered, line_start, column_offset, visible_length)
96
+ ranges = @editor_state.selection_ranges
97
+ return rendered if ranges.empty?
98
+
99
+ selection_ranges = ranges.filter_map do |range|
100
+ selection_start = [range[0] - line_start - column_offset.to_i, 0].max
101
+ selection_end = [range[1] - line_start - column_offset.to_i, visible_length].min
102
+ [selection_start, selection_end] if selection_start < selection_end
103
+ end
104
+ return rendered if selection_ranges.empty?
105
+
106
+ editor_overlay_selection(rendered, selection_ranges)
107
+ end
108
+
109
+ def editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible_length, text_width)
110
+ return rendered unless @color_enabled
111
+
112
+ cursor_columns = @editor_state.secondary_cursor_offsets.filter_map do |offset|
113
+ column = offset - line_start - column_offset.to_i
114
+ column if column >= 0 && column <= visible_length
115
+ end
116
+ return rendered if cursor_columns.empty?
117
+
118
+ if cursor_columns.include?(visible_length) && visible_length < text_width
119
+ rendered += " "
120
+ end
121
+
122
+ editor_overlay_selection(rendered, cursor_columns.map { |column| [column, column + 1] })
123
+ end
124
+
125
+ def editor_overlay_selection(rendered, selection_ranges)
126
+ return rendered unless @color_enabled
127
+
128
+ output = +""
129
+ selected = false
130
+ visible_index = 0
131
+ index = 0
132
+ while index < rendered.length
133
+ if rendered[index] == "\e" && (match = rendered[index..].match(/\A\e\[[0-9;:]*m/))
134
+ output << match[0]
135
+ output << "\e[7m" if selected && match[0] == "\e[0m"
136
+ index += match[0].length
137
+ next
138
+ end
139
+
140
+ should_select = selection_ranges.any? { |range| visible_index >= range[0] && visible_index < range[1] }
141
+ if should_select != selected
142
+ output << (should_select ? "\e[7m" : "\e[27m")
143
+ selected = should_select
144
+ end
145
+ output << rendered[index]
146
+ visible_index += 1
147
+ index += 1
148
+ end
149
+ output << "\e[27m" if selected
150
+ output
151
+ end
152
+
153
+ def editor_render_visible_line(line, line_index)
154
+ return editor_render_diff_line(line) if @editor_state.diff_view?
155
+
156
+ editor_highlight_line(line, line_index)
157
+ end
158
+
159
+ def editor_render_diff_line(line)
160
+ text = line.to_s
161
+ return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
162
+ return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
163
+ return colored(text, :cyan) if text.start_with?("@@")
164
+
165
+ text
166
+ end
167
+
168
+ def editor_line_number_gutter_width
169
+ [[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
170
+ end
171
+
172
+ def editor_text_width(content_width, gutter_width = editor_line_number_gutter_width)
173
+ [content_width - gutter_width, 1].max
174
+ end
175
+
176
+ def editor_visual_rows(text_width)
177
+ @editor_state.lines.each_with_index.flat_map do |line, line_index|
178
+ count = editor_visual_row_count(line, text_width)
179
+ count.times.map do |index|
180
+ column_offset = index * text_width
181
+ { line_index: line_index, column_offset: column_offset, line: line, continuation: index.positive? }
182
+ end
183
+ end
184
+ end
185
+
186
+ def editor_visual_row_count(line, text_width)
187
+ length = line.to_s.length
188
+ return 1 if length.zero?
189
+
190
+ ((length - 1) / text_width) + 1
191
+ end
192
+
193
+ def editor_visual_row_for(line_index, column, text_width)
194
+ before = @editor_state.lines.first(line_index).sum { |line| editor_visual_row_count(line, text_width) }
195
+ before + (editor_visual_row_start_column(line_index, column, text_width) / text_width)
196
+ end
197
+
198
+ def editor_visual_row_start_column(line_index, column, text_width)
199
+ line = @editor_state.lines[line_index].to_s
200
+ return 0 if column.to_i.zero?
201
+ return column.to_i - text_width if column.to_i == line.length && (column.to_i % text_width).zero?
202
+
203
+ (column.to_i / text_width) * text_width
204
+ end
205
+
206
+ def editor_line_number_gutter(line_index)
207
+ number = editor_display_line_number(line_index).to_s.rjust(editor_line_number_gutter_width - 3)
208
+ colored("#{number} │ ", :dark_forest_green)
209
+ end
210
+
211
+ def editor_display_line_number(line_index)
212
+ return line_index + 1 unless current_editor_line_numbers == "relative"
213
+ return line_index + 1 if @editor_state.readonly?
214
+
215
+ cursor_line, = @editor_state.cursor_line_and_column
216
+ line_index == cursor_line ? line_index + 1 : (line_index - cursor_line).abs
217
+ end
218
+
219
+ def editor_blank_line_number_gutter
220
+ colored(" " * editor_line_number_gutter_width, :dark_forest_green)
221
+ end
222
+
223
+ def editor_top_border(width)
224
+ title_prefix = @editor_state.diff_view? ? "Diff" : "Edit"
225
+ dirty_marker = @editor_state.dirty? && !@editor_state.readonly? ? " *" : ""
226
+ title = visible_truncate("#{title_prefix} #{editor_display_path}#{dirty_marker}", [width - 4, 1].max)
227
+ plain_title = ANSI.strip(title)
228
+ "#{colored("╭", :primary_green)} #{title} #{colored("─" * [width - plain_title.length - 4, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
229
+ end
230
+
231
+ def editor_display_path
232
+ Pathname.new(@editor_state.path).relative_path_from(Pathname.new(Dir.pwd)).to_s
233
+ rescue StandardError
234
+ @editor_state.path
235
+ end
236
+
237
+ def editor_status_text
238
+ text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
239
+ visible_truncate(text, [screen_width - 4, 1].max)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,76 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Incremental search state and operations for editor buffers.
6
+ class EditorSearch
7
+ attr_reader :query, :direction
8
+
9
+ def initialize(direction: :forward)
10
+ @active = false
11
+ @query = +""
12
+ @direction = direction
13
+ end
14
+
15
+ def active?
16
+ @active == true
17
+ end
18
+
19
+ def begin(direction = :forward)
20
+ @active = true
21
+ @direction = direction
22
+ @query = +""
23
+ status_prefix
24
+ end
25
+
26
+ def cancel
27
+ @active = false
28
+ "Search cancelled"
29
+ end
30
+
31
+ def append(text)
32
+ @query << text.to_s
33
+ "#{status_prefix} #{@query}"
34
+ end
35
+
36
+ def delete_character
37
+ @query = @query[0...-1].to_s
38
+ "#{status_prefix} #{@query}"
39
+ end
40
+
41
+ def confirm(buffer:, cursor:)
42
+ confirmed_query = @query.to_s
43
+ @active = false
44
+ return { status: "Search cancelled", found: false } if confirmed_query.empty?
45
+
46
+ repeat(buffer: buffer, cursor: cursor, direction: @direction, query: confirmed_query)
47
+ end
48
+
49
+ def repeat(buffer:, cursor:, direction: @direction, query: @query)
50
+ query = query.to_s
51
+ return { status: "No previous search", found: false } if query.empty?
52
+
53
+ @query = query
54
+ @direction = direction
55
+ index = if direction == :backward
56
+ search_from = cursor.positive? ? cursor - 1 : buffer.length
57
+ buffer.rindex(query, search_from) || buffer.rindex(query)
58
+ else
59
+ buffer.index(query, cursor + 1) || buffer.index(query)
60
+ end
61
+
62
+ if index
63
+ { cursor: index, status: "Found: #{query}", found: true }
64
+ else
65
+ { status: "No match: #{query}", found: false }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def status_prefix
72
+ @direction == :backward ? "Search backward:" : "Search:"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,120 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Primary and secondary selection/cursor storage for editor buffers.
6
+ class EditorSelections
7
+ attr_reader :anchor, :secondary
8
+
9
+ def initialize(cursor:, buffer_length:, anchor: nil, secondary: [])
10
+ @cursor = cursor
11
+ @buffer_length = buffer_length
12
+ @anchor = anchor.nil? ? nil : clamp_offset(anchor)
13
+ @secondary = secondary.map { |selection| normalized_selection(selection) }
14
+ normalize
15
+ end
16
+
17
+ def cursor=(value)
18
+ @cursor = value
19
+ normalize
20
+ end
21
+
22
+ def buffer_length=(value)
23
+ @buffer_length = value.to_i
24
+ @anchor = clamp_offset(@anchor) unless @anchor.nil?
25
+ @secondary = @secondary.map { |selection| normalized_selection(selection) }
26
+ normalize
27
+ end
28
+
29
+ def anchor=(value)
30
+ @anchor = value.nil? ? nil : clamp_offset(value)
31
+ normalize
32
+ end
33
+
34
+ def all
35
+ normalize
36
+ [primary] + @secondary.map(&:dup)
37
+ end
38
+
39
+ def multi_cursor?
40
+ normalize
41
+ @secondary.any?
42
+ end
43
+
44
+ def set(values)
45
+ first, *rest = values.to_a
46
+ if first
47
+ @anchor = first[:anchor]
48
+ @cursor = first[:cursor]
49
+ else
50
+ @anchor = nil
51
+ @cursor = 0
52
+ end
53
+ @secondary = rest.map { |selection| normalized_selection(selection) }
54
+ normalize
55
+ end
56
+
57
+ def add(anchor, cursor = anchor)
58
+ @secondary << normalized_selection(anchor: anchor, cursor: cursor)
59
+ normalize
60
+ end
61
+
62
+ def clear
63
+ @anchor = nil
64
+ @secondary = []
65
+ end
66
+
67
+ def collapse_to_primary
68
+ @secondary = []
69
+ @anchor = nil
70
+ end
71
+
72
+ def secondary_cursor_offsets
73
+ normalize
74
+ @secondary.filter_map do |selection|
75
+ selection[:cursor] if selection[:anchor] == selection[:cursor]
76
+ end
77
+ end
78
+
79
+ def primary
80
+ { anchor: @anchor || @cursor, cursor: @cursor }
81
+ end
82
+
83
+ def primary_active?(vibe_visual: false)
84
+ return false if @anchor.nil?
85
+ return true if vibe_visual
86
+
87
+ @anchor != @cursor
88
+ end
89
+
90
+ def range_for(selection)
91
+ [selection[:anchor], selection[:cursor]].minmax
92
+ end
93
+
94
+ private
95
+
96
+ def normalize
97
+ seen = { [primary[:anchor], primary[:cursor]] => true }
98
+ @secondary = @secondary.filter_map do |selection|
99
+ normalized = normalized_selection(selection)
100
+ key = [normalized[:anchor], normalized[:cursor]]
101
+ next if seen[key]
102
+
103
+ seen[key] = true
104
+ normalized
105
+ end
106
+ end
107
+
108
+ def normalized_selection(selection)
109
+ {
110
+ anchor: clamp_offset(selection[:anchor]),
111
+ cursor: clamp_offset(selection[:cursor])
112
+ }
113
+ end
114
+
115
+ def clamp_offset(value)
116
+ [[value.to_i, 0].max, @buffer_length].min
117
+ end
118
+ end
119
+ end
120
+ end