kward 0.70.0 → 0.71.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -0,0 +1,120 @@
1
+ require "digest"
2
+ require "json"
3
+ require "net/http"
4
+ require "time"
5
+ require "uri"
6
+ require_relative "config_files"
7
+ require_relative "private_file"
8
+
9
+ # Namespace for the Kward CLI agent runtime.
10
+ module Kward
11
+ # Fetches and stores the OpenRouter model list available to the configured API key.
12
+ class OpenRouterModelCache
13
+ MODELS_URL = URI("https://openrouter.ai/api/v1/models/user")
14
+ VERSION = 1
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(api_key:, path: ConfigFiles.openrouter_models_cache_path)
19
+ @api_key = api_key.to_s
20
+ @path = File.expand_path(path)
21
+ end
22
+
23
+ def self.model_entry?(entry)
24
+ return false unless entry.is_a?(Hash)
25
+
26
+ architecture = entry["architecture"].is_a?(Hash) ? entry["architecture"] : {}
27
+ input_modalities = Array(architecture["input_modalities"]).map(&:to_s)
28
+ output_modalities = Array(architecture["output_modalities"]).map(&:to_s)
29
+ input_modalities.include?("text") && output_modalities.include?("text")
30
+ end
31
+
32
+ def refresh
33
+ raise "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config." if @api_key.empty?
34
+
35
+ response = Net::HTTP.start(MODELS_URL.hostname, MODELS_URL.port, use_ssl: true) do |http|
36
+ http.request(refresh_request)
37
+ end
38
+ unless response.is_a?(Net::HTTPSuccess)
39
+ raise "OpenRouter model refresh failed: #{response.code} #{redact(response.body)}"
40
+ end
41
+
42
+ entries = model_entries(response.body)
43
+ models = entries.select { |entry| self.class.model_entry?(entry) }.map { |entry| normalize_model(entry) }.uniq { |model| model["id"] }.sort_by { |model| model["id"] }
44
+ data = cache_data(models)
45
+ PrivateFile.write_json(@path, data)
46
+ data
47
+ end
48
+
49
+ def read
50
+ return nil unless File.exist?(@path)
51
+
52
+ data = JSON.parse(File.read(@path))
53
+ return nil unless data.is_a?(Hash) && data["version"] == VERSION
54
+
55
+ data
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def models
61
+ Array(read&.fetch("models", []))
62
+ end
63
+
64
+ def matching_key?
65
+ data = read
66
+ return false unless data
67
+
68
+ data["api_key_sha256"] == api_key_sha256
69
+ end
70
+
71
+ private
72
+
73
+ def refresh_request
74
+ Net::HTTP::Get.new(MODELS_URL).tap do |request|
75
+ request["Authorization"] = "Bearer #{@api_key}"
76
+ request["Accept"] = "application/json"
77
+ end
78
+ end
79
+
80
+ def model_entries(body)
81
+ data = JSON.parse(body.to_s)
82
+ entries = data.is_a?(Hash) ? data["data"] || [] : data
83
+ Array(entries).select { |entry| entry.is_a?(Hash) }
84
+ rescue JSON::ParserError
85
+ raise "OpenRouter model refresh returned invalid JSON"
86
+ end
87
+
88
+ def normalize_model(entry)
89
+ {
90
+ "provider" => "OpenRouter",
91
+ "id" => entry.fetch("id").to_s,
92
+ "name" => entry["name"].to_s.empty? ? entry.fetch("id").to_s : entry["name"].to_s,
93
+ "contextWindow" => entry["context_length"],
94
+ "supportedParameters" => Array(entry["supported_parameters"]).map(&:to_s)
95
+ }.compact
96
+ end
97
+
98
+ def cache_data(models)
99
+ {
100
+ "version" => VERSION,
101
+ "refreshed_at" => Time.now.utc.iso8601,
102
+ "source" => MODELS_URL.to_s,
103
+ "filter" => {
104
+ "input_modalities" => ["text"],
105
+ "output_modalities" => ["text"]
106
+ },
107
+ "api_key_sha256" => api_key_sha256,
108
+ "models" => models
109
+ }
110
+ end
111
+
112
+ def api_key_sha256
113
+ Digest::SHA256.hexdigest(@api_key)
114
+ end
115
+
116
+ def redact(text)
117
+ text.to_s.gsub(@api_key, "[REDACTED]")
118
+ end
119
+ end
120
+ end
@@ -95,7 +95,19 @@ module Kward
95
95
  end
96
96
  end
97
97
 
98
- # DSL object yielded by `Kward.plugin` blocks.
98
+ # Public DSL object yielded by `Kward.plugin` blocks.
99
+ #
100
+ # Plugin files normally interact with this object only through a block:
101
+ #
102
+ # @example Register a plugin command
103
+ # Kward.plugin do |plugin|
104
+ # plugin.command "hello", description: "Say hello" do |args, ctx|
105
+ # name = args.strip.empty? ? "there" : args.strip
106
+ # ctx.say "Hello, #{name}."
107
+ # end
108
+ # end
109
+ #
110
+ # @api public
99
111
  class DSL
100
112
  # Creates an object for trusted plugin loading and dispatch.
101
113
  def initialize(registry, path)
@@ -105,33 +117,53 @@ module Kward
105
117
 
106
118
  # Registers a slash command.
107
119
  #
120
+ # The command is available in the interactive CLI and through the RPC
121
+ # command bridge. Command names do not include the leading `/`.
122
+ #
108
123
  # @param name [String, #to_s] command name without the leading slash
109
124
  # @param description [String] short text shown in command listings
110
125
  # @param argument_hint [String] optional usage hint for arguments
111
126
  # @yieldparam args [String] text after the command name
112
127
  # @yieldparam ctx [Context] plugin execution context
128
+ # @return [void]
129
+ # @api public
113
130
  def command(name, description: "", argument_hint: "", &block)
114
131
  @registry.register_command(name, description: description, argument_hint: argument_hint, path: @path, &block)
115
132
  end
116
133
 
117
134
  # Registers or replaces the custom footer renderer.
118
135
  #
136
+ # Only one footer renderer is active. If multiple plugins register one,
137
+ # the later renderer replaces the earlier renderer.
138
+ #
119
139
  # @yieldparam ctx [Context] plugin execution context
140
+ # @return [void]
141
+ # @api public
120
142
  def footer(&block)
121
143
  @registry.register_footer(path: @path, &block)
122
144
  end
123
145
 
124
146
  # Registers a live transcript event observer.
125
147
  #
148
+ # Observer errors are caught and reported as warnings so a plugin cannot
149
+ # crash the active turn by raising from an event handler.
150
+ #
126
151
  # @yieldparam event [TranscriptEvent] normalized transcript event
127
152
  # @yieldparam ctx [Context] plugin execution context
153
+ # @return [void]
154
+ # @api public
128
155
  def on_transcript_event(&block)
129
156
  @registry.register_transcript_event(path: @path, &block)
130
157
  end
131
158
 
132
159
  # Registers prompt context text injected into future system prompts.
133
160
  #
161
+ # Keep this text short and never include secrets. The returned string can
162
+ # be sent to the active model as part of Kward's system instructions.
163
+ #
134
164
  # @yieldparam ctx [Context] plugin execution context
165
+ # @return [void]
166
+ # @api public
135
167
  def prompt_context(&block)
136
168
  @registry.register_prompt_context(path: @path, &block)
137
169
  end
@@ -179,11 +211,15 @@ module Kward
179
211
  @footer_path = nil
180
212
  @transcript_event_handlers = []
181
213
  @prompt_context_renderers = []
214
+ @paths = []
182
215
  end
183
216
 
184
217
  # @return [String, nil] plugin file currently responsible for footer output
185
218
  attr_reader :footer_path
186
219
 
220
+ # @return [Array<String>] plugin files successfully loaded by this registry
221
+ attr_reader :paths
222
+
187
223
  def commands
188
224
  @commands.values
189
225
  end
@@ -233,6 +269,7 @@ module Kward
233
269
  self.class.loading_registry = self
234
270
  self.class.loading_path = path
235
271
  Kernel.load(path)
272
+ @paths << path
236
273
  rescue StandardError => e
237
274
  warn "Warning: skipping Kward plugin #{path}: #{e.message}"
238
275
  ensure
@@ -329,6 +366,15 @@ module Kward
329
366
  end
330
367
  end
331
368
 
369
+ # Registers a trusted local plugin.
370
+ #
371
+ # This method is intended for Ruby files loaded from the user plugin
372
+ # directory. It raises if called outside plugin loading so workspace code
373
+ # cannot silently mutate Kward's runtime by merely being required.
374
+ #
375
+ # @yieldparam plugin [PluginRegistry::DSL] plugin registration DSL
376
+ # @return [Object, nil] the plugin block result
377
+ # @api public
332
378
  def self.plugin(&block)
333
379
  registry = PluginRegistry.loading_registry
334
380
  raise "Kward.plugin can only be called while loading a plugin" unless registry
@@ -1,80 +1,45 @@
1
- require_relative "../ansi"
2
- require_relative "../resources/avatar_kward_logo"
3
- require_relative "../resources/pixel_logo"
4
-
5
1
  # Namespace for the Kward CLI agent runtime.
6
2
  module Kward
7
- # Startup banner logo and message renderer.
3
+ # Startup banner message renderer.
8
4
  class PromptInterface
9
5
  # Startup banner rendering data and helpers for the prompt interface.
10
6
  class Banner
11
- LOGO_WIDTH = 32
12
- LOGO_PIXEL_HEIGHT = 32
13
- MIN_LOGO_HEIGHT = 4
14
- LOGO_PIXELS = Kward::Resources::AvatarKwardLogo::PIXELS
15
7
  MESSAGE = "State your business.".freeze
16
8
 
17
- def initialize(message:, pixels:, screen_height:, minimum_composer_rows: 3)
9
+ def initialize(message:, screen_height:, minimum_composer_rows: 3)
18
10
  @message = message.to_s
19
- @pixels = pixels
20
11
  @screen_height = screen_height
21
12
  @minimum_composer_rows = minimum_composer_rows
22
- @logo_cache = {}
23
13
  end
24
14
 
25
- def rows(width)
26
- return [] unless visible?(width)
15
+ def rows(width, message: nil)
16
+ content = message.nil? ? @message : message.to_s
17
+ return [] if content.empty? || max_banner_rows <= 0
27
18
 
28
- rows = []
29
- rows.concat(centered_image_rows(width)) if image_visible?(width)
30
- rows << align_plain_row(@message, width) unless @message.empty?
31
- rows << ""
32
- rows
19
+ visible_lines(content) + [""]
33
20
  end
34
21
 
35
- def logo_rows(width)
36
- logo_width, logo_height = logo_dimensions(width)
37
- return [] unless @pixels && max_logo_height >= MIN_LOGO_HEIGHT
38
-
39
- key = [logo_width, logo_height]
40
- @logo_cache[key] ||= Kward::PixelLogo.half_block_rows_from_pixels(@pixels, width: logo_width, pixel_height: logo_height)
22
+ def logo_rows(_width)
23
+ []
41
24
  end
42
25
 
43
26
  private
44
27
 
45
- def visible?(width)
46
- !@message.empty? || image_visible?(width)
47
- end
48
-
49
- def image_visible?(width)
50
- !logo_rows(width).empty?
51
- end
52
-
53
- def centered_image_rows(width)
54
- logo_width, = logo_dimensions(width)
55
- padding = [[(width - logo_width) / 2, 0].max, width - 1].min
56
- logo_rows(width).map { |row| (" " * padding) + row }
57
- end
28
+ def visible_lines(content)
29
+ lines = content.lines(chomp: true)
30
+ return lines if lines.length <= max_banner_rows
31
+ return [lines.last] if max_banner_rows == 1
58
32
 
59
- def logo_dimensions(width)
60
- logo_width = [LOGO_WIDTH, [width - 2, 1].max].min
61
- logo_height = [LOGO_PIXEL_HEIGHT, max_logo_height * 2].min
62
- [logo_width, logo_height]
33
+ lines.first(max_banner_rows - 1) + [lines.last]
63
34
  end
64
35
 
65
- def max_logo_height
66
- message_rows = @message.empty? ? 0 : 1
67
- blank_after_banner = 1
36
+ def max_banner_rows
68
37
  transcript_row = 1
69
- reserved_rows = message_rows + blank_after_banner + @minimum_composer_rows + transcript_row
38
+ blank_after_banner = 1
39
+ reserved_rows = blank_after_banner + @minimum_composer_rows + transcript_row
70
40
  [@screen_height.call - reserved_rows, 0].max
71
41
  end
72
42
 
73
- def align_plain_row(text, width)
74
- plain_length = ANSI.strip(text).length
75
- padding = [width - plain_length, 0].max / 2
76
- (" " * padding) + text.to_s
77
- end
78
43
  end
79
44
  end
80
45
  end
@@ -26,14 +26,6 @@ module Kward
26
26
  @composer.attachments
27
27
  end
28
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
29
  def insert_key(key)
38
30
  return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
39
31
 
@@ -46,8 +38,8 @@ module Kward
46
38
  reset_slash_selection
47
39
  reset_history_navigation
48
40
  @slash_overlay_dismissed_input = nil
49
- @composer.insert_string(string)
50
- end
41
+ @composer.insert_string(string)
42
+ end
51
43
 
52
44
  def insert_paste(string)
53
45
  parsed = parse_attachments(string)
@@ -70,11 +62,11 @@ module Kward
70
62
  end
71
63
 
72
64
  def add_attachment(attachment)
73
- @composer.add_attachment(attachment)
74
- end
65
+ @composer.add_attachment(attachment)
66
+ end
75
67
 
76
68
  def delete_before_cursor
77
- if @composer.cursor.zero?
69
+ if @composer.cursor.zero?
78
70
  remove_last_attachment
79
71
  return
80
72
  end
@@ -82,48 +74,48 @@ module Kward
82
74
  reset_slash_selection
83
75
  reset_history_navigation
84
76
  @composer.delete_before_cursor
85
- end
77
+ end
86
78
 
87
79
  def remove_last_attachment
88
- return unless @composer.remove_last_attachment
80
+ return unless @composer.remove_last_attachment
89
81
 
90
82
  reset_slash_selection
91
83
  reset_history_navigation
92
84
  @slash_overlay_dismissed_input = nil
93
- end
85
+ end
94
86
 
95
87
  def delete_at_cursor
96
- return unless @composer.cursor < @composer.input.length
88
+ return unless @composer.cursor < @composer.input.length
97
89
 
98
90
  reset_slash_selection
99
91
  reset_history_navigation
100
92
  @slash_overlay_dismissed_input = nil
101
93
  @composer.delete_at_cursor
102
- end
94
+ end
103
95
 
104
96
  def move_cursor_left
105
- @composer.move_cursor_left
106
- end
97
+ @composer.move_cursor_left
98
+ end
107
99
 
108
100
  def move_cursor_right
109
- @composer.move_cursor_right
110
- end
101
+ @composer.move_cursor_right
102
+ end
111
103
 
112
104
  def move_to_start_of_line
113
- @composer.move_to_start_of_line
114
- end
105
+ @composer.move_to_start_of_line
106
+ end
115
107
 
116
108
  def move_to_end_of_line
117
- @composer.move_to_end_of_line
118
- end
109
+ @composer.move_to_end_of_line
110
+ end
119
111
 
120
112
  def move_to_previous_word
121
- @composer.move_to_previous_word
122
- end
113
+ @composer.move_to_previous_word
114
+ end
123
115
 
124
116
  def move_to_next_word
125
- @composer.move_to_next_word
126
- end
117
+ @composer.move_to_next_word
118
+ end
127
119
 
128
120
  def delete_at_cursor_or_exit
129
121
  composer_input.empty? ? exit_input : delete_at_cursor
@@ -132,65 +124,46 @@ module Kward
132
124
  def delete_word_before_cursor
133
125
  reset_slash_selection
134
126
  reset_history_navigation
135
- @composer.delete_word_before_cursor
136
- end
127
+ @composer.delete_word_before_cursor
128
+ end
137
129
 
138
130
  def delete_word_after_cursor
139
131
  reset_slash_selection
140
132
  reset_history_navigation
141
- @composer.delete_word_after_cursor
142
- end
133
+ @composer.delete_word_after_cursor
134
+ end
143
135
 
144
136
  def kill_line_before_cursor
145
137
  reset_slash_selection
146
138
  reset_history_navigation
147
- @composer.kill_line_before_cursor
148
- end
139
+ @composer.kill_line_before_cursor
140
+ end
149
141
 
150
142
  def kill_line_after_cursor
151
143
  reset_slash_selection
152
144
  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)
145
+ @composer.kill_line_after_cursor
169
146
  end
170
147
 
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)
148
+ def yank_kill_buffer
149
+ @composer.yank_kill_buffer
177
150
  end
178
151
 
179
152
  def add_history(value)
180
- @composer.add_history(value)
181
- end
153
+ @composer.add_history(value)
154
+ end
182
155
 
183
156
  def recall_previous_history
184
- @composer.recall_previous_history
185
- end
157
+ @composer.recall_previous_history
158
+ end
186
159
 
187
160
  def recall_next_history
188
- @composer.recall_next_history
189
- end
161
+ @composer.recall_next_history
162
+ end
190
163
 
191
164
  def replace_input(value)
192
165
  @composer.replace_input(value)
193
- end
166
+ end
194
167
 
195
168
  def prefill_input(value)
196
169
  @mutex.synchronize do
@@ -199,19 +172,36 @@ module Kward
199
172
  end
200
173
 
201
174
  def reset_history_navigation
202
- @composer.reset_history_navigation
203
- end
204
-
175
+ @composer.reset_history_navigation
176
+ end
177
+
178
+ def prepare_modal_input_locked(label, clear_attachments: false)
179
+ @prompt_label = label.to_s
180
+ self.composer_input = ""
181
+ self.composer_cursor = 0
182
+ @composer.clear_attachments if clear_attachments
183
+ @pending_keys.clear
184
+ @asking = true
185
+ @busy = false
186
+ @queued_count = 0
187
+ reset_history_navigation
188
+ end
205
189
 
206
190
  def submit_input
207
191
  value = submitted_input
208
192
  add_history(composer_input)
193
+ clear_finished_input_locked(reset_history: true)
194
+ @output_io.flush
195
+ value
196
+ end
197
+
198
+ def clear_finished_input_locked(reset_history: false)
209
199
  if @busy
210
200
  clear_prompt_for_output_locked
211
201
  self.composer_input = ""
212
202
  self.composer_cursor = 0
213
203
  @composer.clear_attachments
214
- reset_history_navigation
204
+ reset_history_navigation if reset_history
215
205
  @asking = true
216
206
  render_prompt_after_output_locked
217
207
  else
@@ -223,8 +213,6 @@ module Kward
223
213
  @rendered_rows = 0
224
214
  @cursor_rendered_row = 0
225
215
  end
226
- @output_io.flush
227
- value
228
216
  end
229
217
 
230
218
  def submitted_input
@@ -237,22 +225,7 @@ module Kward
237
225
  end
238
226
 
239
227
  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
228
+ clear_finished_input_locked
256
229
  @output_io.flush
257
230
  EXIT_INPUT
258
231
  end
@@ -85,7 +85,7 @@ module Kward
85
85
  end
86
86
 
87
87
  def composer_title
88
- label = @prompt_label.delete_suffix(">")
88
+ label = composer_title_label
89
89
  if @busy && @queued_count.positive?
90
90
  status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
91
91
  elsif @busy && @steered_count.to_i.positive?
@@ -97,6 +97,12 @@ module Kward
97
97
  end
98
98
  end
99
99
 
100
+ def composer_title_label
101
+ return "Search" if @select_state && select_search_active?
102
+
103
+ @prompt_label.delete_suffix(">")
104
+ end
105
+
100
106
  def busy_title(text)
101
107
  @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
102
108
  end
@@ -137,8 +137,17 @@ module Kward
137
137
  end
138
138
 
139
139
  def handle_bracketed_paste_key(key)
140
+ paste = read_bracketed_paste(key)
141
+ return false unless paste
142
+
143
+ insert_paste(normalize_paste(paste[:content]))
144
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
145
+ true
146
+ end
147
+
148
+ def read_bracketed_paste(key)
140
149
  text = key.to_s
141
- return false unless text.start_with?(BRACKETED_PASTE_START)
150
+ return nil unless text.start_with?(BRACKETED_PASTE_START)
142
151
 
143
152
  pasted = text[BRACKETED_PASTE_START.length..] || ""
144
153
  until pasted.include?(BRACKETED_PASTE_END)
@@ -149,9 +158,7 @@ module Kward
149
158
  end
150
159
 
151
160
  content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
152
- insert_paste(normalize_paste(content || ""))
153
- queue_pending_keys(remaining) if remaining && !remaining.empty?
154
- true
161
+ { content: content || "", remaining: remaining }
155
162
  end
156
163
 
157
164
  def normalize_paste(content)
@@ -159,13 +166,12 @@ module Kward
159
166
  end
160
167
 
161
168
  def handle_csi_u_key(key)
162
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
163
- return false unless match
169
+ sequence = parse_csi_u_key(key)
170
+ return false unless sequence
164
171
 
165
- sequence = match[0]
166
- code = match[1].to_i
167
- modifier = (match[2] || "1").split(":", 2).first.to_i
168
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
172
+ code = sequence[:code]
173
+ modifier = sequence[:modifier]
174
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
169
175
 
170
176
  case code
171
177
  when 13
@@ -182,6 +188,21 @@ module Kward
182
188
  end
183
189
  end
184
190
 
191
+ def parse_csi_u_key(key)
192
+ match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
193
+ return nil unless match
194
+
195
+ modifiers = match[2].to_s
196
+ modifier = (modifiers.empty? ? "1" : modifiers).split(":", 2).first.to_i
197
+ {
198
+ sequence: match[0],
199
+ code: match[1].to_i,
200
+ modifiers: modifiers,
201
+ modifier: modifier,
202
+ remaining: key.to_s[match[0].length..]
203
+ }
204
+ end
205
+
185
206
  def handle_modified_csi_u_key(code, modifier)
186
207
  return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
187
208
 
@@ -6,8 +6,8 @@ module Kward
6
6
  module Layout
7
7
  private
8
8
 
9
- def banner_rows(width)
10
- @banner.rows(width)
9
+ def banner_rows(width, message: nil)
10
+ @banner.rows(width, message: message)
11
11
  end
12
12
 
13
13
  def banner_logo_rows