kward 0.67.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- 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
|
data/lib/kward/private_file.rb
CHANGED
|
@@ -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
|