kward 0.67.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -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