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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +48 -2
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +1 -15
- data/doc/context-tools.md +70 -0
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +7 -8
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +80 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +260 -11
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +24 -6
- data/lib/kward/cli.rb +13 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -20
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +1 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +22 -28
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +17 -6
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +256 -7
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +91 -0
- data/templates/default/layout/html/layout.erb +39 -8
- data/templates/default/layout/html/setup.rb +33 -38
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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
|
|
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:,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
36
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
message_rows = @message.empty? ? 0 : 1
|
|
67
|
-
blank_after_banner = 1
|
|
36
|
+
def max_banner_rows
|
|
68
37
|
transcript_row = 1
|
|
69
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
74
|
-
|
|
65
|
+
@composer.add_attachment(attachment)
|
|
66
|
+
end
|
|
75
67
|
|
|
76
68
|
def delete_before_cursor
|
|
77
|
-
|
|
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
|
-
|
|
77
|
+
end
|
|
86
78
|
|
|
87
79
|
def remove_last_attachment
|
|
88
|
-
|
|
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
|
-
|
|
85
|
+
end
|
|
94
86
|
|
|
95
87
|
def delete_at_cursor
|
|
96
|
-
|
|
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
|
-
|
|
94
|
+
end
|
|
103
95
|
|
|
104
96
|
def move_cursor_left
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
@composer.move_cursor_left
|
|
98
|
+
end
|
|
107
99
|
|
|
108
100
|
def move_cursor_right
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
@composer.move_cursor_right
|
|
102
|
+
end
|
|
111
103
|
|
|
112
104
|
def move_to_start_of_line
|
|
113
|
-
|
|
114
|
-
|
|
105
|
+
@composer.move_to_start_of_line
|
|
106
|
+
end
|
|
115
107
|
|
|
116
108
|
def move_to_end_of_line
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
@composer.move_to_end_of_line
|
|
110
|
+
end
|
|
119
111
|
|
|
120
112
|
def move_to_previous_word
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
@composer.move_to_previous_word
|
|
114
|
+
end
|
|
123
115
|
|
|
124
116
|
def move_to_next_word
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
|
|
153
|
+
@composer.add_history(value)
|
|
154
|
+
end
|
|
182
155
|
|
|
183
156
|
def recall_previous_history
|
|
184
|
-
|
|
185
|
-
|
|
157
|
+
@composer.recall_previous_history
|
|
158
|
+
end
|
|
186
159
|
|
|
187
160
|
def recall_next_history
|
|
188
|
-
|
|
189
|
-
|
|
161
|
+
@composer.recall_next_history
|
|
162
|
+
end
|
|
190
163
|
|
|
191
164
|
def replace_input(value)
|
|
192
165
|
@composer.replace_input(value)
|
|
193
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
return false unless
|
|
169
|
+
sequence = parse_csi_u_key(key)
|
|
170
|
+
return false unless sequence
|
|
164
171
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|