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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -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 +36 -9
- data/lib/kward/rpc/session_manager.rb +121 -345
- 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 +114 -24
- 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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Mutable text, cursor, history, and overlay state for the composer.
|
|
6
|
+
class ComposerState
|
|
7
|
+
# @return [String] editable text currently shown in the composer
|
|
8
|
+
attr_accessor :input
|
|
9
|
+
# @return [Integer] cursor offset into `input`
|
|
10
|
+
attr_accessor :cursor
|
|
11
|
+
# @return [String] most recently killed text available for yank
|
|
12
|
+
attr_accessor :kill_buffer
|
|
13
|
+
# @return [Integer, nil] active history index while navigating history
|
|
14
|
+
attr_accessor :history_index
|
|
15
|
+
# @return [String, nil] draft restored after leaving history navigation
|
|
16
|
+
attr_accessor :history_draft
|
|
17
|
+
# @return [String, nil] text queued for the next composer prompt
|
|
18
|
+
attr_accessor :prefill_input
|
|
19
|
+
# @return [Array<Hash>] pending image/file attachments submitted with the next turn
|
|
20
|
+
attr_reader :attachments
|
|
21
|
+
# @return [Array<String>] submitted input history
|
|
22
|
+
attr_reader :history
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@input = +""
|
|
26
|
+
@cursor = 0
|
|
27
|
+
@attachments = []
|
|
28
|
+
@kill_buffer = ""
|
|
29
|
+
@history = []
|
|
30
|
+
@history_index = nil
|
|
31
|
+
@history_draft = nil
|
|
32
|
+
@prefill_input = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Removes all pending attachments without changing text input.
|
|
36
|
+
def clear_attachments
|
|
37
|
+
@attachments.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Adds one attachment unless its source is already pending.
|
|
41
|
+
def add_attachment(attachment)
|
|
42
|
+
return false unless attachment.respond_to?(:key?)
|
|
43
|
+
|
|
44
|
+
source = attachment[:source_text] || attachment["source_text"] || attachment[:original_path] || attachment["original_path"]
|
|
45
|
+
return false if source.to_s.empty?
|
|
46
|
+
return false if @attachments.any? { |item| (item[:source_text] || item["source_text"]).to_s == source.to_s }
|
|
47
|
+
|
|
48
|
+
@attachments << attachment
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Removes the most recently added attachment.
|
|
53
|
+
def remove_last_attachment
|
|
54
|
+
return false if @attachments.empty?
|
|
55
|
+
|
|
56
|
+
@attachments.pop
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Inserts text at the cursor and advances by the inserted length.
|
|
61
|
+
def insert_string(string)
|
|
62
|
+
return if string.empty?
|
|
63
|
+
|
|
64
|
+
@input = @input[0...@cursor] + string + @input[@cursor..]
|
|
65
|
+
@cursor += string.length
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deletes one character before the cursor.
|
|
69
|
+
def delete_before_cursor
|
|
70
|
+
return false if @cursor.zero?
|
|
71
|
+
|
|
72
|
+
@input = @input[0...(@cursor - 1)] + @input[@cursor..]
|
|
73
|
+
@cursor -= 1
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deletes one character at the cursor without moving it.
|
|
78
|
+
def delete_at_cursor
|
|
79
|
+
return false unless @cursor < @input.length
|
|
80
|
+
|
|
81
|
+
@input = @input[0...@cursor] + @input[(@cursor + 1)..]
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Moves the cursor one character left when possible.
|
|
86
|
+
def move_cursor_left
|
|
87
|
+
@cursor -= 1 if @cursor.positive?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Moves the cursor one character right when possible.
|
|
91
|
+
def move_cursor_right
|
|
92
|
+
@cursor += 1 if @cursor < @input.length
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Moves the cursor to the beginning of the input buffer.
|
|
96
|
+
def move_to_start_of_line
|
|
97
|
+
@cursor = 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Moves the cursor to the end of the input buffer.
|
|
101
|
+
def move_to_end_of_line
|
|
102
|
+
@cursor = @input.length
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Moves the cursor to the previous word boundary.
|
|
106
|
+
def move_to_previous_word
|
|
107
|
+
@cursor = previous_word_boundary(@cursor)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Moves the cursor to the next word boundary.
|
|
111
|
+
def move_to_next_word
|
|
112
|
+
@cursor = next_word_boundary(@cursor)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Kills the word before the cursor into `kill_buffer`.
|
|
116
|
+
def delete_word_before_cursor
|
|
117
|
+
kill_range(previous_word_boundary(@cursor), @cursor)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Kills the word after the cursor into `kill_buffer`.
|
|
121
|
+
def delete_word_after_cursor
|
|
122
|
+
kill_range(@cursor, next_word_boundary(@cursor))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Kills all text before the cursor into `kill_buffer`.
|
|
126
|
+
def kill_line_before_cursor
|
|
127
|
+
kill_range(0, @cursor)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Kills all text after the cursor into `kill_buffer`.
|
|
131
|
+
def kill_line_after_cursor
|
|
132
|
+
kill_range(@cursor, @input.length)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Removes a range, stores it in `kill_buffer`, and moves the cursor to the start.
|
|
136
|
+
def kill_range(start_index, end_index)
|
|
137
|
+
return false if start_index == end_index
|
|
138
|
+
|
|
139
|
+
@kill_buffer = @input[start_index...end_index].to_s
|
|
140
|
+
@input = @input[0...start_index].to_s + @input[end_index..].to_s
|
|
141
|
+
@cursor = start_index
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Inserts the last killed text at the cursor.
|
|
146
|
+
def yank_kill_buffer
|
|
147
|
+
insert_string(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Finds the start offset of the word before `index`.
|
|
151
|
+
def previous_word_boundary(index)
|
|
152
|
+
cursor = index
|
|
153
|
+
cursor -= 1 while cursor.positive? && word_separator?(@input[cursor - 1])
|
|
154
|
+
cursor -= 1 while cursor.positive? && !word_separator?(@input[cursor - 1])
|
|
155
|
+
cursor
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Finds the end offset of the word after `index`.
|
|
159
|
+
def next_word_boundary(index)
|
|
160
|
+
cursor = index
|
|
161
|
+
cursor += 1 while cursor < @input.length && word_separator?(@input[cursor])
|
|
162
|
+
cursor += 1 while cursor < @input.length && !word_separator?(@input[cursor])
|
|
163
|
+
cursor
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Treats whitespace as the only word separator for composer navigation.
|
|
167
|
+
def word_separator?(char)
|
|
168
|
+
char.to_s.match?(/\s/)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Replaces the full input buffer and places the cursor at the end.
|
|
172
|
+
def replace_input(value)
|
|
173
|
+
@input = value.to_s
|
|
174
|
+
@cursor = @input.length
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns `[row, column]` for cursor placement in multi-line input.
|
|
178
|
+
def cursor_logical_position
|
|
179
|
+
before_cursor = @input[0...@cursor]
|
|
180
|
+
[before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Stores a submitted input unless it is blank or duplicates the previous entry.
|
|
184
|
+
def add_history(value)
|
|
185
|
+
stripped = value.to_s.strip
|
|
186
|
+
return if stripped.empty?
|
|
187
|
+
return if @history.last == value
|
|
188
|
+
|
|
189
|
+
@history << value
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Replaces input with the previous history entry, preserving the draft first.
|
|
193
|
+
def recall_previous_history
|
|
194
|
+
return if @history.empty?
|
|
195
|
+
|
|
196
|
+
@history_draft = @input if @history_index.nil?
|
|
197
|
+
@history_index = @history_index.nil? ? @history.length - 1 : [@history_index - 1, 0].max
|
|
198
|
+
replace_input(@history[@history_index])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Replaces input with the next history entry or restores the saved draft.
|
|
202
|
+
def recall_next_history
|
|
203
|
+
return if @history_index.nil?
|
|
204
|
+
|
|
205
|
+
if @history_index < @history.length - 1
|
|
206
|
+
@history_index += 1
|
|
207
|
+
replace_input(@history[@history_index])
|
|
208
|
+
else
|
|
209
|
+
replace_input(@history_draft || "")
|
|
210
|
+
reset_history_navigation
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Leaves history navigation and clears the saved draft/index state.
|
|
215
|
+
def reset_history_navigation
|
|
216
|
+
@history_index = nil
|
|
217
|
+
@history_draft = nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Keyboard sequence dispatcher for composer and overlay input.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Keyboard sequence handling for the terminal prompt interface.
|
|
6
|
+
module KeyHandler
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def read_key(nonblock: false)
|
|
10
|
+
pending = @pending_keys.shift unless @pending_keys.empty?
|
|
11
|
+
return pending if pending
|
|
12
|
+
|
|
13
|
+
@reader.read_keypress(echo: false, raw: true, nonblock: nonblock)
|
|
14
|
+
rescue TTY::Reader::InputInterrupt
|
|
15
|
+
"\x03"
|
|
16
|
+
rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle_key(key)
|
|
21
|
+
return submit_input if key.nil?
|
|
22
|
+
return if handle_bracketed_paste_key(key)
|
|
23
|
+
|
|
24
|
+
csi_result = handle_csi_u_key(key)
|
|
25
|
+
return csi_result unless csi_result == false
|
|
26
|
+
return if handle_shift_enter_key(key)
|
|
27
|
+
if key.is_a?(String) && key.length > 1
|
|
28
|
+
token = next_key_token(key)
|
|
29
|
+
if token.length < key.length
|
|
30
|
+
queue_pending_keys(key[token.length..])
|
|
31
|
+
return handle_key(token)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
binding_result = handle_composer_key_binding(key)
|
|
36
|
+
return binding_result unless binding_result == false
|
|
37
|
+
|
|
38
|
+
key_name = @reader.console.keys[key]
|
|
39
|
+
case key_name
|
|
40
|
+
when :return, :enter
|
|
41
|
+
submit_input
|
|
42
|
+
when :backspace
|
|
43
|
+
delete_before_cursor
|
|
44
|
+
when :delete
|
|
45
|
+
delete_at_cursor
|
|
46
|
+
when :ctrl_d
|
|
47
|
+
delete_at_cursor_or_exit
|
|
48
|
+
when :ctrl_c
|
|
49
|
+
cancel_input_or_interrupt
|
|
50
|
+
when :ctrl_a
|
|
51
|
+
move_to_start_of_line
|
|
52
|
+
when :ctrl_e
|
|
53
|
+
move_to_end_of_line
|
|
54
|
+
when :ctrl_b
|
|
55
|
+
move_cursor_left
|
|
56
|
+
when :ctrl_f
|
|
57
|
+
move_cursor_right
|
|
58
|
+
when :ctrl_w
|
|
59
|
+
delete_word_before_cursor
|
|
60
|
+
when :ctrl_u
|
|
61
|
+
kill_line_before_cursor
|
|
62
|
+
when :ctrl_k
|
|
63
|
+
kill_line_after_cursor
|
|
64
|
+
when :ctrl_y
|
|
65
|
+
yank_kill_buffer
|
|
66
|
+
when :ctrl_l
|
|
67
|
+
redraw_screen_locked
|
|
68
|
+
when :left
|
|
69
|
+
move_cursor_left
|
|
70
|
+
when :right
|
|
71
|
+
move_cursor_right
|
|
72
|
+
when :home
|
|
73
|
+
move_to_start_of_line
|
|
74
|
+
when :end
|
|
75
|
+
move_to_end_of_line
|
|
76
|
+
when :up
|
|
77
|
+
slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
|
|
78
|
+
when :down
|
|
79
|
+
slash_overlay_visible? ? select_next_slash_command : recall_next_history
|
|
80
|
+
else
|
|
81
|
+
case key
|
|
82
|
+
when "\n", "\r"
|
|
83
|
+
submit_input
|
|
84
|
+
when "\t"
|
|
85
|
+
complete_selected_slash_command || insert_key(key)
|
|
86
|
+
when "\b", "\x7F"
|
|
87
|
+
delete_before_cursor
|
|
88
|
+
when "\x04"
|
|
89
|
+
delete_at_cursor_or_exit
|
|
90
|
+
when "\x03"
|
|
91
|
+
cancel_input_or_interrupt
|
|
92
|
+
when "\e"
|
|
93
|
+
handle_escape_sequence
|
|
94
|
+
else
|
|
95
|
+
insert_key(key)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def cancel_input_or_interrupt
|
|
101
|
+
return CANCEL_INPUT if @busy
|
|
102
|
+
|
|
103
|
+
raise Interrupt
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_escape_sequence
|
|
107
|
+
pending_sequence = read_pending_escape_sequence
|
|
108
|
+
return true if pending_sequence.empty? && dismiss_slash_overlay
|
|
109
|
+
|
|
110
|
+
full_sequence = "\e#{pending_sequence}"
|
|
111
|
+
sequence = next_key_token(full_sequence)
|
|
112
|
+
queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
|
|
113
|
+
return true if sequence == "\e" && dismiss_slash_overlay
|
|
114
|
+
return true if handle_shift_enter_key(sequence)
|
|
115
|
+
|
|
116
|
+
binding_result = handle_composer_key_binding(sequence)
|
|
117
|
+
return binding_result unless binding_result == false
|
|
118
|
+
|
|
119
|
+
key_name = @reader.console.keys[sequence]
|
|
120
|
+
case key_name
|
|
121
|
+
when :up
|
|
122
|
+
slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
|
|
123
|
+
when :down
|
|
124
|
+
slash_overlay_visible? ? select_next_slash_command : recall_next_history
|
|
125
|
+
when :left
|
|
126
|
+
move_cursor_left
|
|
127
|
+
when :right
|
|
128
|
+
move_cursor_right
|
|
129
|
+
when :home
|
|
130
|
+
move_to_start_of_line
|
|
131
|
+
when :end
|
|
132
|
+
move_to_end_of_line
|
|
133
|
+
when :delete
|
|
134
|
+
delete_at_cursor
|
|
135
|
+
end
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_bracketed_paste_key(key)
|
|
140
|
+
text = key.to_s
|
|
141
|
+
return false unless text.start_with?(BRACKETED_PASTE_START)
|
|
142
|
+
|
|
143
|
+
pasted = text[BRACKETED_PASTE_START.length..] || ""
|
|
144
|
+
until pasted.include?(BRACKETED_PASTE_END)
|
|
145
|
+
chunk = @reader.read_keypress(echo: false, raw: true)
|
|
146
|
+
break if chunk.nil?
|
|
147
|
+
|
|
148
|
+
pasted << chunk.to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
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
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_paste(content)
|
|
158
|
+
content.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_csi_u_key(key)
|
|
162
|
+
match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
|
|
163
|
+
return false unless match
|
|
164
|
+
|
|
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
|
|
169
|
+
|
|
170
|
+
case code
|
|
171
|
+
when 13
|
|
172
|
+
modifier == 2 ? insert_string("\n") : submit_input
|
|
173
|
+
when 27
|
|
174
|
+
dismiss_slash_overlay || false
|
|
175
|
+
when 8, 127
|
|
176
|
+
alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
|
|
177
|
+
nil
|
|
178
|
+
when 4
|
|
179
|
+
delete_at_cursor_or_exit
|
|
180
|
+
else
|
|
181
|
+
handle_modified_csi_u_key(code, modifier)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def handle_modified_csi_u_key(code, modifier)
|
|
186
|
+
return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
|
|
187
|
+
|
|
188
|
+
normalized_code = code.to_i.chr.downcase.ord rescue code
|
|
189
|
+
if ctrl_modifier?(modifier)
|
|
190
|
+
case normalized_code
|
|
191
|
+
when 97
|
|
192
|
+
move_to_start_of_line
|
|
193
|
+
when 98
|
|
194
|
+
move_cursor_left
|
|
195
|
+
when 99
|
|
196
|
+
cancel_input_or_interrupt
|
|
197
|
+
when 100
|
|
198
|
+
delete_at_cursor_or_exit
|
|
199
|
+
when 101
|
|
200
|
+
move_to_end_of_line
|
|
201
|
+
when 102
|
|
202
|
+
move_cursor_right
|
|
203
|
+
when 104
|
|
204
|
+
delete_before_cursor
|
|
205
|
+
when 107
|
|
206
|
+
kill_line_after_cursor
|
|
207
|
+
when 108
|
|
208
|
+
redraw_screen_locked
|
|
209
|
+
when 117
|
|
210
|
+
kill_line_before_cursor
|
|
211
|
+
when 119
|
|
212
|
+
delete_word_before_cursor
|
|
213
|
+
when 121
|
|
214
|
+
yank_kill_buffer
|
|
215
|
+
else
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
elsif alt_modifier?(modifier)
|
|
219
|
+
case normalized_code
|
|
220
|
+
when 98
|
|
221
|
+
move_to_previous_word
|
|
222
|
+
when 100
|
|
223
|
+
delete_word_after_cursor
|
|
224
|
+
when 102
|
|
225
|
+
move_to_next_word
|
|
226
|
+
else
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
false
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def ctrl_modifier?(modifier)
|
|
235
|
+
((modifier.to_i - 1) & 4).positive?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def alt_modifier?(modifier)
|
|
239
|
+
((modifier.to_i - 1) & 2).positive?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def handle_shift_enter_key(key)
|
|
243
|
+
sequence = shift_enter_sequence_for(key)
|
|
244
|
+
return false unless sequence
|
|
245
|
+
|
|
246
|
+
insert_string("\n")
|
|
247
|
+
queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
|
|
248
|
+
true
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def queue_pending_keys(keys)
|
|
252
|
+
remaining = keys.to_s
|
|
253
|
+
until remaining.empty?
|
|
254
|
+
token = next_key_token(remaining)
|
|
255
|
+
@pending_keys << token
|
|
256
|
+
remaining = remaining[token.length..] || ""
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def next_key_token(keys)
|
|
261
|
+
text = keys.to_s
|
|
262
|
+
text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
|
|
263
|
+
text.match(/\A\eO[A-Za-z]/)&.[](0) ||
|
|
264
|
+
shift_enter_sequence_for(text) ||
|
|
265
|
+
(text.start_with?("\e") && text.length > 1 && alt_key_sequence?(text[1]) ? text[0, 2] : text[0, 1])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def alt_key_sequence?(char)
|
|
269
|
+
char = char.to_s
|
|
270
|
+
char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def shift_enter_sequence_for(key)
|
|
274
|
+
return nil unless key.is_a?(String)
|
|
275
|
+
|
|
276
|
+
SHIFT_ENTER_SEQUENCES.find { |sequence| key.start_with?(sequence) }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def read_pending_escape_sequence
|
|
280
|
+
sequence = +""
|
|
281
|
+
until @pending_keys.empty?
|
|
282
|
+
sequence << @pending_keys.shift.to_s
|
|
283
|
+
end
|
|
284
|
+
while (char = @reader.read_keypress(echo: false, raw: true, nonblock: true))
|
|
285
|
+
sequence << char.to_s
|
|
286
|
+
end
|
|
287
|
+
sequence
|
|
288
|
+
rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
|
|
289
|
+
sequence
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def handle_composer_key_binding(key)
|
|
293
|
+
case key
|
|
294
|
+
when "\x01"
|
|
295
|
+
move_to_start_of_line
|
|
296
|
+
when "\x02"
|
|
297
|
+
move_cursor_left
|
|
298
|
+
when "\x04"
|
|
299
|
+
delete_at_cursor_or_exit
|
|
300
|
+
when "\x05"
|
|
301
|
+
move_to_end_of_line
|
|
302
|
+
when "\x06"
|
|
303
|
+
move_cursor_right
|
|
304
|
+
when "\x0B"
|
|
305
|
+
kill_line_after_cursor
|
|
306
|
+
when "\x0C"
|
|
307
|
+
redraw_screen_locked
|
|
308
|
+
when "\x15"
|
|
309
|
+
kill_line_before_cursor
|
|
310
|
+
when "\x17"
|
|
311
|
+
delete_word_before_cursor
|
|
312
|
+
when "\x19"
|
|
313
|
+
yank_kill_buffer
|
|
314
|
+
when "\e[D", "\eOD"
|
|
315
|
+
move_cursor_left
|
|
316
|
+
when "\e[C", "\eOC"
|
|
317
|
+
move_cursor_right
|
|
318
|
+
when "\e[H", "\eOH", "\e[1~", "\e[7~"
|
|
319
|
+
move_to_start_of_line
|
|
320
|
+
when "\e[F", "\eOF", "\e[4~", "\e[8~"
|
|
321
|
+
move_to_end_of_line
|
|
322
|
+
when "\e[3~"
|
|
323
|
+
delete_at_cursor
|
|
324
|
+
when "\eb", "\eB"
|
|
325
|
+
move_to_previous_word
|
|
326
|
+
when "\ef", "\eF"
|
|
327
|
+
move_to_next_word
|
|
328
|
+
when "\ed", "\eD"
|
|
329
|
+
delete_word_after_cursor
|
|
330
|
+
when "\e\b", "\e\x7F"
|
|
331
|
+
delete_word_before_cursor
|
|
332
|
+
else
|
|
333
|
+
handle_modified_ansi_key(key) || false
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def handle_modified_ansi_key(key)
|
|
338
|
+
match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/)
|
|
339
|
+
if match
|
|
340
|
+
modifier = match[2].to_i
|
|
341
|
+
final = match[3]
|
|
342
|
+
return false unless alt_modifier?(modifier)
|
|
343
|
+
|
|
344
|
+
case final
|
|
345
|
+
when "C"
|
|
346
|
+
move_to_next_word
|
|
347
|
+
when "D"
|
|
348
|
+
move_to_previous_word
|
|
349
|
+
when "F"
|
|
350
|
+
move_to_end_of_line
|
|
351
|
+
when "H"
|
|
352
|
+
move_to_start_of_line
|
|
353
|
+
else
|
|
354
|
+
false
|
|
355
|
+
end
|
|
356
|
+
elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
|
|
357
|
+
alt_modifier?(match[1].to_i) ? delete_word_after_cursor : delete_at_cursor
|
|
358
|
+
else
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Layout calculations for terminal rows and overlay placement.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Terminal layout calculations for transcript, overlays, footer, and composer.
|
|
6
|
+
module Layout
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def banner_rows(width)
|
|
10
|
+
@banner.rows(width)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def banner_logo_rows
|
|
14
|
+
@banner.logo_rows(screen_width)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def transcript_redraw_row_count(height = screen_height)
|
|
18
|
+
[[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def composer_top_row(height = screen_height)
|
|
22
|
+
[height - @reserved_rows + 1, 1].max
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def transcript_bottom_row(height = screen_height)
|
|
26
|
+
[height - @reserved_rows, 1].max
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|