kward 0.67.1 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -1,11 +1,17 @@
1
1
  require_relative "config_files"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
4
5
  # Loads trusted user plugin files and provides the plugin DSL.
5
6
  #
6
7
  # Plugins live in the user plugin directory, run as local Ruby code, and can
7
8
  # register slash commands, one footer renderer, prompt context, and live
8
9
  # transcript-event observers for CLI and RPC frontends.
10
+ #
11
+ # This registry is intentionally trust-based, not a sandbox. Keep plugin loading
12
+ # restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
13
+ # load path, and expose immutable transcript views so plugins can observe state
14
+ # without corrupting active conversations.
9
15
  class PluginRegistry
10
16
  COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
11
17
 
@@ -26,6 +32,7 @@ module Kward
26
32
 
27
33
  # Read-only transcript view exposed to plugin code.
28
34
  class Transcript
35
+ # Creates an object for trusted plugin loading and dispatch.
29
36
  def initialize(conversation)
30
37
  @conversation = conversation
31
38
  end
@@ -43,6 +50,7 @@ module Kward
43
50
  class Context
44
51
  attr_reader :args, :workspace_root
45
52
 
53
+ # Creates an object for trusted plugin loading and dispatch.
46
54
  def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil)
47
55
  @conversation = conversation
48
56
  @args = args.to_s
@@ -89,6 +97,7 @@ module Kward
89
97
 
90
98
  # DSL object yielded by `Kward.plugin` blocks.
91
99
  class DSL
100
+ # Creates an object for trusted plugin loading and dispatch.
92
101
  def initialize(registry, path)
93
102
  @registry = registry
94
103
  @path = path
@@ -128,6 +137,7 @@ module Kward
128
137
  end
129
138
  end
130
139
 
140
+ # Mutable singleton guard used while loading trusted plugin files.
131
141
  class << self
132
142
  attr_accessor :loading_registry, :loading_path
133
143
 
@@ -161,6 +171,7 @@ module Kward
161
171
  end
162
172
  end
163
173
 
174
+ # Creates an object for trusted plugin loading and dispatch.
164
175
  def initialize(reserved_commands: [])
165
176
  @reserved_commands = reserved_commands.map(&:to_s)
166
177
  @commands = {}
@@ -170,6 +181,7 @@ module Kward
170
181
  @prompt_context_renderers = []
171
182
  end
172
183
 
184
+ # @return [String, nil] plugin file currently responsible for footer output
173
185
  attr_reader :footer_path
174
186
 
175
187
  def commands
@@ -1,7 +1,9 @@
1
1
  require "fileutils"
2
2
  require "json"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Writes sensitive JSON files with private filesystem permissions.
5
7
  module PrivateFile
6
8
  module_function
7
9
 
@@ -2,8 +2,11 @@ require_relative "../ansi"
2
2
  require_relative "../resources/avatar_kward_logo"
3
3
  require_relative "../resources/pixel_logo"
4
4
 
5
+ # Namespace for the Kward CLI agent runtime.
5
6
  module Kward
7
+ # Startup banner logo and message renderer.
6
8
  class PromptInterface
9
+ # Startup banner rendering data and helpers for the prompt interface.
7
10
  class Banner
8
11
  LOGO_WIDTH = 32
9
12
  LOGO_PIXEL_HEIGHT = 32
@@ -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