kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -0,0 +1,262 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # High-level composer input loop and submission controller.
4
+ class PromptInterface
5
+ # Text composer state transitions for keyboard input, paste, history, and submission.
6
+ module ComposerController
7
+ private
8
+
9
+ def composer_input
10
+ @composer.input
11
+ end
12
+
13
+ def composer_input=(value)
14
+ @composer.input = value.to_s
15
+ end
16
+
17
+ def composer_cursor
18
+ @composer.cursor
19
+ end
20
+
21
+ def composer_cursor=(value)
22
+ @composer.cursor = value.to_i
23
+ end
24
+
25
+ def composer_attachments
26
+ @composer.attachments
27
+ end
28
+
29
+ def composer_kill_buffer
30
+ @composer.kill_buffer
31
+ end
32
+
33
+ def composer_kill_buffer=(value)
34
+ @composer.kill_buffer = value.to_s
35
+ end
36
+
37
+ def insert_key(key)
38
+ return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
39
+
40
+ insert_string(key)
41
+ end
42
+
43
+ def insert_string(string)
44
+ return if string.empty?
45
+
46
+ reset_slash_selection
47
+ reset_history_navigation
48
+ @slash_overlay_dismissed_input = nil
49
+ @composer.insert_string(string)
50
+ end
51
+
52
+ def insert_paste(string)
53
+ parsed = parse_attachments(string)
54
+ Array(parsed[:attachments]).each { |attachment| add_attachment(attachment) }
55
+ insert_string(parsed[:text].to_s) unless parsed[:text].to_s.empty?
56
+ end
57
+
58
+ def parse_attachments(string)
59
+ return { text: string.to_s, attachments: [] } unless @attachment_parser
60
+
61
+ result = @attachment_parser.call(string.to_s)
62
+ return { text: string.to_s, attachments: [] } unless result.is_a?(Hash)
63
+
64
+ {
65
+ text: result[:text] || result["text"] || "",
66
+ attachments: result[:attachments] || result["attachments"] || []
67
+ }
68
+ rescue StandardError
69
+ { text: string.to_s, attachments: [] }
70
+ end
71
+
72
+ def add_attachment(attachment)
73
+ @composer.add_attachment(attachment)
74
+ end
75
+
76
+ def delete_before_cursor
77
+ if @composer.cursor.zero?
78
+ remove_last_attachment
79
+ return
80
+ end
81
+
82
+ reset_slash_selection
83
+ reset_history_navigation
84
+ @composer.delete_before_cursor
85
+ end
86
+
87
+ def remove_last_attachment
88
+ return unless @composer.remove_last_attachment
89
+
90
+ reset_slash_selection
91
+ reset_history_navigation
92
+ @slash_overlay_dismissed_input = nil
93
+ end
94
+
95
+ def delete_at_cursor
96
+ return unless @composer.cursor < @composer.input.length
97
+
98
+ reset_slash_selection
99
+ reset_history_navigation
100
+ @slash_overlay_dismissed_input = nil
101
+ @composer.delete_at_cursor
102
+ end
103
+
104
+ def move_cursor_left
105
+ @composer.move_cursor_left
106
+ end
107
+
108
+ def move_cursor_right
109
+ @composer.move_cursor_right
110
+ end
111
+
112
+ def move_to_start_of_line
113
+ @composer.move_to_start_of_line
114
+ end
115
+
116
+ def move_to_end_of_line
117
+ @composer.move_to_end_of_line
118
+ end
119
+
120
+ def move_to_previous_word
121
+ @composer.move_to_previous_word
122
+ end
123
+
124
+ def move_to_next_word
125
+ @composer.move_to_next_word
126
+ end
127
+
128
+ def delete_at_cursor_or_exit
129
+ composer_input.empty? ? exit_input : delete_at_cursor
130
+ end
131
+
132
+ def delete_word_before_cursor
133
+ reset_slash_selection
134
+ reset_history_navigation
135
+ @composer.delete_word_before_cursor
136
+ end
137
+
138
+ def delete_word_after_cursor
139
+ reset_slash_selection
140
+ reset_history_navigation
141
+ @composer.delete_word_after_cursor
142
+ end
143
+
144
+ def kill_line_before_cursor
145
+ reset_slash_selection
146
+ reset_history_navigation
147
+ @composer.kill_line_before_cursor
148
+ end
149
+
150
+ def kill_line_after_cursor
151
+ reset_slash_selection
152
+ reset_history_navigation
153
+ @composer.kill_line_after_cursor
154
+ end
155
+
156
+ def kill_range(start_index, end_index)
157
+ return unless @composer.kill_range(start_index, end_index)
158
+
159
+ reset_slash_selection
160
+ reset_history_navigation
161
+ end
162
+
163
+ def yank_kill_buffer
164
+ @composer.yank_kill_buffer
165
+ end
166
+
167
+ def previous_word_boundary(index)
168
+ @composer.previous_word_boundary(index)
169
+ end
170
+
171
+ def next_word_boundary(index)
172
+ @composer.next_word_boundary(index)
173
+ end
174
+
175
+ def word_separator?(char)
176
+ @composer.word_separator?(char)
177
+ end
178
+
179
+ def add_history(value)
180
+ @composer.add_history(value)
181
+ end
182
+
183
+ def recall_previous_history
184
+ @composer.recall_previous_history
185
+ end
186
+
187
+ def recall_next_history
188
+ @composer.recall_next_history
189
+ end
190
+
191
+ def replace_input(value)
192
+ @composer.replace_input(value)
193
+ end
194
+
195
+ def prefill_input(value)
196
+ @mutex.synchronize do
197
+ @composer.prefill_input = value.to_s
198
+ end
199
+ end
200
+
201
+ def reset_history_navigation
202
+ @composer.reset_history_navigation
203
+ end
204
+
205
+
206
+ def submit_input
207
+ value = submitted_input
208
+ add_history(composer_input)
209
+ if @busy
210
+ clear_prompt_for_output_locked
211
+ self.composer_input = ""
212
+ self.composer_cursor = 0
213
+ @composer.clear_attachments
214
+ reset_history_navigation
215
+ @asking = true
216
+ render_prompt_after_output_locked
217
+ else
218
+ clear_prompt_locked
219
+ self.composer_input = ""
220
+ self.composer_cursor = 0
221
+ @composer.clear_attachments
222
+ @asking = false
223
+ @rendered_rows = 0
224
+ @cursor_rendered_row = 0
225
+ end
226
+ @output_io.flush
227
+ value
228
+ end
229
+
230
+ def submitted_input
231
+ return composer_input if composer_attachments.empty?
232
+
233
+ sources = composer_attachments.map { |attachment| attachment[:source_text].to_s }.reject(&:empty?)
234
+ display_input = composer_input.to_s.rstrip
235
+ full_input = [display_input, *sources].reject { |part| part.to_s.strip.empty? }.join("\n")
236
+ SubmittedInput.new(full_input, display_input: display_input)
237
+ end
238
+
239
+ def exit_input
240
+ if @busy
241
+ clear_prompt_for_output_locked
242
+ self.composer_input = ""
243
+ self.composer_cursor = 0
244
+ @composer.clear_attachments
245
+ @asking = true
246
+ render_prompt_after_output_locked
247
+ else
248
+ clear_prompt_locked
249
+ self.composer_input = ""
250
+ self.composer_cursor = 0
251
+ @composer.clear_attachments
252
+ @asking = false
253
+ @rendered_rows = 0
254
+ @cursor_rendered_row = 0
255
+ end
256
+ @output_io.flush
257
+ EXIT_INPUT
258
+ end
259
+
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,172 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Renderer for the editable composer text area.
4
+ class PromptInterface
5
+ # Renderer for the editable prompt composer area.
6
+ module ComposerRenderer
7
+ private
8
+
9
+ def composer_layout(width, height = screen_height)
10
+ return compact_composer_layout(width) if height < 4
11
+ return question_composer_layout(width, height) if @question_state
12
+
13
+ content_width = [width - 4, 1].max
14
+ input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
15
+ attachment_rows = attachment_badge_rows(content_width)
16
+ overlay_rows = active_overlay_rows(width, height: height)
17
+ footer_text = footer_text()
18
+ max_input_rows = max_visible_input_rows(attachment_rows.length, overlay_rows.length, footer_text.empty? ? 0 : 1, height: height)
19
+ visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
20
+ visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
21
+ rows = overlay_rows + [top_border(width)]
22
+ rows.concat(attachment_rows)
23
+ rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
24
+ rows << footer_row(content_width, footer_text) unless footer_text.empty?
25
+ rows << bottom_border(width)
26
+ cursor_row = overlay_rows.length + 1 + attachment_rows.length + input_cursor_row - visible_start
27
+ cursor_col = 2 + [input_cursor_col, content_width - 1].min
28
+ [rows, cursor_row, cursor_col]
29
+ end
30
+
31
+ def compact_composer_layout(width)
32
+ cursor_line, cursor_col = cursor_logical_position
33
+ prefix = "#{@prompt_label} "
34
+ line = input_lines[cursor_line] || ""
35
+ input_width = [width - prefix.length, 1].max
36
+ visible_start = [[cursor_col - input_width + 1, 0].max, [line.length - input_width, 0].max].min
37
+ visible = line[visible_start, input_width].to_s
38
+ row = "#{prefix}#{visible}"[0, width].to_s.ljust(width)
39
+ [[row], 0, [prefix.length + cursor_col - visible_start, width - 1].min]
40
+ end
41
+
42
+ def input_layout(content_width)
43
+ cursor_line, cursor_col = cursor_logical_position
44
+ rows = []
45
+ cursor_row = 0
46
+ rendered_row_offset = 0
47
+
48
+ input_lines.each_with_index do |line, index|
49
+ prefix = input_prefix(index)
50
+ continuation_prefix = " " * prefix.length
51
+ available = [content_width - prefix.length, 1].max
52
+ chunks = line.scan(/.{1,#{available}}/m)
53
+ chunks = [""] if chunks.empty?
54
+ if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
55
+ chunks << ""
56
+ end
57
+
58
+ if index == cursor_line
59
+ cursor_row = rendered_row_offset + (cursor_col / available)
60
+ end
61
+
62
+ chunks.each_with_index do |chunk, chunk_index|
63
+ rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
64
+ end
65
+ rendered_row_offset += chunks.length
66
+ end
67
+
68
+ prefix = input_prefix(cursor_line)
69
+ available = [content_width - prefix.length, 1].max
70
+ cursor_col_in_row = prefix.length + (cursor_col % available)
71
+ [rows, cursor_row, cursor_col_in_row]
72
+ end
73
+
74
+ def top_border(width)
75
+ title = composer_title
76
+ status = composer_status_text
77
+ if status
78
+ gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
79
+ if gap >= 0
80
+ return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
81
+ end
82
+ end
83
+ plain_title = ANSI.strip(title)
84
+ "#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title.length - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
85
+ end
86
+
87
+ def composer_title
88
+ label = @prompt_label.delete_suffix(">")
89
+ if @busy && @queued_count.positive?
90
+ status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
91
+ elsif @busy && @steered_count.to_i.positive?
92
+ status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
93
+ elsif @busy
94
+ status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
95
+ else
96
+ status_composer_text(label)
97
+ end
98
+ end
99
+
100
+ def busy_title(text)
101
+ @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
102
+ end
103
+
104
+ def composer_status_text
105
+ text = @composer_status&.call.to_s
106
+ return nil if text.empty?
107
+
108
+ status_composer_text(text)
109
+ end
110
+
111
+ def status_composer_text(text)
112
+ " #{text} "
113
+ end
114
+
115
+ def bottom_border(width)
116
+ colored("╰#{"─" * [width - 2, 0].max}╯", :primary_green)
117
+ end
118
+
119
+ def box_content_row(row, content_width)
120
+ "#{colored("│", :primary_green)} #{row[0, content_width].to_s.ljust(content_width)} #{colored("│", :primary_green)}"
121
+ end
122
+
123
+ def footer_row(content_width, text = footer_text)
124
+ return nil if text.empty?
125
+
126
+ box_content_row(visible_truncate(text, content_width), content_width)
127
+ end
128
+
129
+ def footer_text
130
+ return "" unless @footer
131
+
132
+ @footer.call.to_s.gsub(/\s+/, " ").strip
133
+ rescue StandardError
134
+ ""
135
+ end
136
+
137
+ def attachment_badge_rows(content_width)
138
+ attachment_badge_texts.map { |text| box_content_row(visible_truncate(text, content_width), content_width) }
139
+ end
140
+
141
+ def attachment_badge_texts
142
+ return [] unless @attachment_badges
143
+
144
+ Array(@attachment_badges.call(composer_input, composer_attachments)).map(&:to_s).reject(&:empty?)
145
+ rescue ArgumentError
146
+ Array(@attachment_badges.call(composer_input)).map(&:to_s).reject(&:empty?)
147
+ rescue StandardError
148
+ []
149
+ end
150
+
151
+ def max_visible_input_rows(attachment_count = 0, overlay_count = active_overlay_rows(screen_width).length, footer_count = footer_text.to_s.empty? ? 0 : 1, height: screen_height)
152
+ input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
153
+ [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
154
+ end
155
+
156
+ def input_lines
157
+ lines = composer_input.split("\n", -1)
158
+ lines.empty? ? [""] : lines
159
+ end
160
+
161
+ def input_prefix(_index)
162
+ ""
163
+ end
164
+
165
+ def cursor_logical_position
166
+ before_cursor = composer_input[0...composer_cursor]
167
+ [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
168
+ end
169
+
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,221 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Mutable text, cursor, history, and overlay state for the composer.
6
+ class ComposerState
7
+ # @return [String] editable text currently shown in the composer
8
+ attr_accessor :input
9
+ # @return [Integer] cursor offset into `input`
10
+ attr_accessor :cursor
11
+ # @return [String] most recently killed text available for yank
12
+ attr_accessor :kill_buffer
13
+ # @return [Integer, nil] active history index while navigating history
14
+ attr_accessor :history_index
15
+ # @return [String, nil] draft restored after leaving history navigation
16
+ attr_accessor :history_draft
17
+ # @return [String, nil] text queued for the next composer prompt
18
+ attr_accessor :prefill_input
19
+ # @return [Array<Hash>] pending image/file attachments submitted with the next turn
20
+ attr_reader :attachments
21
+ # @return [Array<String>] submitted input history
22
+ attr_reader :history
23
+
24
+ def initialize
25
+ @input = +""
26
+ @cursor = 0
27
+ @attachments = []
28
+ @kill_buffer = ""
29
+ @history = []
30
+ @history_index = nil
31
+ @history_draft = nil
32
+ @prefill_input = nil
33
+ end
34
+
35
+ # Removes all pending attachments without changing text input.
36
+ def clear_attachments
37
+ @attachments.clear
38
+ end
39
+
40
+ # Adds one attachment unless its source is already pending.
41
+ def add_attachment(attachment)
42
+ return false unless attachment.respond_to?(:key?)
43
+
44
+ source = attachment[:source_text] || attachment["source_text"] || attachment[:original_path] || attachment["original_path"]
45
+ return false if source.to_s.empty?
46
+ return false if @attachments.any? { |item| (item[:source_text] || item["source_text"]).to_s == source.to_s }
47
+
48
+ @attachments << attachment
49
+ true
50
+ end
51
+
52
+ # Removes the most recently added attachment.
53
+ def remove_last_attachment
54
+ return false if @attachments.empty?
55
+
56
+ @attachments.pop
57
+ true
58
+ end
59
+
60
+ # Inserts text at the cursor and advances by the inserted length.
61
+ def insert_string(string)
62
+ return if string.empty?
63
+
64
+ @input = @input[0...@cursor] + string + @input[@cursor..]
65
+ @cursor += string.length
66
+ end
67
+
68
+ # Deletes one character before the cursor.
69
+ def delete_before_cursor
70
+ return false if @cursor.zero?
71
+
72
+ @input = @input[0...(@cursor - 1)] + @input[@cursor..]
73
+ @cursor -= 1
74
+ true
75
+ end
76
+
77
+ # Deletes one character at the cursor without moving it.
78
+ def delete_at_cursor
79
+ return false unless @cursor < @input.length
80
+
81
+ @input = @input[0...@cursor] + @input[(@cursor + 1)..]
82
+ true
83
+ end
84
+
85
+ # Moves the cursor one character left when possible.
86
+ def move_cursor_left
87
+ @cursor -= 1 if @cursor.positive?
88
+ end
89
+
90
+ # Moves the cursor one character right when possible.
91
+ def move_cursor_right
92
+ @cursor += 1 if @cursor < @input.length
93
+ end
94
+
95
+ # Moves the cursor to the beginning of the input buffer.
96
+ def move_to_start_of_line
97
+ @cursor = 0
98
+ end
99
+
100
+ # Moves the cursor to the end of the input buffer.
101
+ def move_to_end_of_line
102
+ @cursor = @input.length
103
+ end
104
+
105
+ # Moves the cursor to the previous word boundary.
106
+ def move_to_previous_word
107
+ @cursor = previous_word_boundary(@cursor)
108
+ end
109
+
110
+ # Moves the cursor to the next word boundary.
111
+ def move_to_next_word
112
+ @cursor = next_word_boundary(@cursor)
113
+ end
114
+
115
+ # Kills the word before the cursor into `kill_buffer`.
116
+ def delete_word_before_cursor
117
+ kill_range(previous_word_boundary(@cursor), @cursor)
118
+ end
119
+
120
+ # Kills the word after the cursor into `kill_buffer`.
121
+ def delete_word_after_cursor
122
+ kill_range(@cursor, next_word_boundary(@cursor))
123
+ end
124
+
125
+ # Kills all text before the cursor into `kill_buffer`.
126
+ def kill_line_before_cursor
127
+ kill_range(0, @cursor)
128
+ end
129
+
130
+ # Kills all text after the cursor into `kill_buffer`.
131
+ def kill_line_after_cursor
132
+ kill_range(@cursor, @input.length)
133
+ end
134
+
135
+ # Removes a range, stores it in `kill_buffer`, and moves the cursor to the start.
136
+ def kill_range(start_index, end_index)
137
+ return false if start_index == end_index
138
+
139
+ @kill_buffer = @input[start_index...end_index].to_s
140
+ @input = @input[0...start_index].to_s + @input[end_index..].to_s
141
+ @cursor = start_index
142
+ true
143
+ end
144
+
145
+ # Inserts the last killed text at the cursor.
146
+ def yank_kill_buffer
147
+ insert_string(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
148
+ end
149
+
150
+ # Finds the start offset of the word before `index`.
151
+ def previous_word_boundary(index)
152
+ cursor = index
153
+ cursor -= 1 while cursor.positive? && word_separator?(@input[cursor - 1])
154
+ cursor -= 1 while cursor.positive? && !word_separator?(@input[cursor - 1])
155
+ cursor
156
+ end
157
+
158
+ # Finds the end offset of the word after `index`.
159
+ def next_word_boundary(index)
160
+ cursor = index
161
+ cursor += 1 while cursor < @input.length && word_separator?(@input[cursor])
162
+ cursor += 1 while cursor < @input.length && !word_separator?(@input[cursor])
163
+ cursor
164
+ end
165
+
166
+ # Treats whitespace as the only word separator for composer navigation.
167
+ def word_separator?(char)
168
+ char.to_s.match?(/\s/)
169
+ end
170
+
171
+ # Replaces the full input buffer and places the cursor at the end.
172
+ def replace_input(value)
173
+ @input = value.to_s
174
+ @cursor = @input.length
175
+ end
176
+
177
+ # Returns `[row, column]` for cursor placement in multi-line input.
178
+ def cursor_logical_position
179
+ before_cursor = @input[0...@cursor]
180
+ [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
181
+ end
182
+
183
+ # Stores a submitted input unless it is blank or duplicates the previous entry.
184
+ def add_history(value)
185
+ stripped = value.to_s.strip
186
+ return if stripped.empty?
187
+ return if @history.last == value
188
+
189
+ @history << value
190
+ end
191
+
192
+ # Replaces input with the previous history entry, preserving the draft first.
193
+ def recall_previous_history
194
+ return if @history.empty?
195
+
196
+ @history_draft = @input if @history_index.nil?
197
+ @history_index = @history_index.nil? ? @history.length - 1 : [@history_index - 1, 0].max
198
+ replace_input(@history[@history_index])
199
+ end
200
+
201
+ # Replaces input with the next history entry or restores the saved draft.
202
+ def recall_next_history
203
+ return if @history_index.nil?
204
+
205
+ if @history_index < @history.length - 1
206
+ @history_index += 1
207
+ replace_input(@history[@history_index])
208
+ else
209
+ replace_input(@history_draft || "")
210
+ reset_history_navigation
211
+ end
212
+ end
213
+
214
+ # Leaves history navigation and clears the saved draft/index state.
215
+ def reset_history_navigation
216
+ @history_index = nil
217
+ @history_draft = nil
218
+ end
219
+ end
220
+ end
221
+ end