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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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 +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  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 +125 -31
  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,186 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Terminal screen lifecycle and escape-sequence management.
4
+ class PromptInterface
5
+ # Terminal screen control and escape-sequence helpers.
6
+ module Screen
7
+ private
8
+
9
+ def enter_raw_mode_locked
10
+ return unless @input_io.respond_to?(:tty?) && @input_io.tty?
11
+ return unless @input_io.respond_to?(:console_mode) && @input_io.respond_to?(:console_mode=)
12
+ return if @raw_mode_active
13
+
14
+ @original_console_mode = @input_io.console_mode
15
+ raw_mode = @input_io.console_mode.raw
16
+ raw_mode.echo = false
17
+ @input_io.console_mode = raw_mode
18
+ @raw_mode_active = true
19
+ rescue StandardError
20
+ @original_console_mode = nil
21
+ @raw_mode_active = false
22
+ end
23
+
24
+ def restore_console_mode_locked
25
+ return unless @raw_mode_active
26
+
27
+ @input_io.console_mode = @original_console_mode if @original_console_mode
28
+ ensure
29
+ @original_console_mode = nil
30
+ @raw_mode_active = false
31
+ end
32
+
33
+ def with_synchronized_output_locked
34
+ if @restoring_transcript || @synchronized_output_depth.positive?
35
+ yield
36
+ return
37
+ end
38
+
39
+ synchronized = true
40
+ @synchronized_output_depth += 1
41
+ @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
42
+ yield
43
+ ensure
44
+ if synchronized
45
+ @synchronized_output_depth -= 1
46
+ @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE) if @synchronized_output_depth.zero?
47
+ end
48
+ end
49
+
50
+ def hide_cursor_for_transcript_output_locked
51
+ return unless @started && @asking
52
+
53
+ set_cursor_visible_locked(false)
54
+ end
55
+
56
+ def render_cursor_visibility_locked
57
+ visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
58
+ set_cursor_visible_locked(visible)
59
+ end
60
+
61
+ def set_cursor_visible_locked(visible, force: false)
62
+ return if !force && @cursor_visible == visible
63
+
64
+ @output_io.print(visible ? CURSOR_SHOW : CURSOR_HIDE)
65
+ @cursor_visible = visible
66
+ end
67
+
68
+ def reserve_composer_region_locked(width: screen_width, height: screen_height)
69
+ rows, = composer_layout(width, height)
70
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
71
+ end
72
+
73
+ def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
74
+ new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
75
+ return if @reserved_rows == new_reserved_rows && @last_height == height
76
+
77
+ old_reserved_rows = @reserved_rows
78
+ rows_to_clear = [old_reserved_rows, new_reserved_rows].max
79
+ @reserved_rows = new_reserved_rows
80
+ @output_io.print("\e[1;#{transcript_bottom_row(height)}r")
81
+ clear_composer_region_locked(rows_to_clear, height: height)
82
+ redraw_transcript_locked(width: width, height: height) if redraw_transcript && new_reserved_rows < old_reserved_rows
83
+ end
84
+
85
+ def handle_resize_locked
86
+ current_width, current_height = screen_size
87
+ return false if current_width == @last_width && current_height == @last_height
88
+
89
+ old_width = @last_width
90
+ old_height = @last_height
91
+ old_reserved_rows = @reserved_rows
92
+ restore_scroll_region_locked
93
+ rows_to_clear = resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
94
+ clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
95
+ @reserved_rows = 0
96
+ @last_width = current_width
97
+ @last_height = current_height
98
+ redraw_screen_locked(width: current_width, height: current_height)
99
+ true
100
+ end
101
+
102
+ def restore_scroll_region_locked
103
+ @output_io.print("\e[r")
104
+ @reserved_rows = 0
105
+ end
106
+
107
+ def render_composer_rows_locked(rows, height: screen_height)
108
+ top = composer_top_row(height)
109
+ max_rows = [@last_composer_rows.length, rows.length].max
110
+ rows_to_clear = [@reserved_rows - rows.length, 0].max
111
+
112
+ max_rows.times do |index|
113
+ row = rows[index]
114
+ previous = @last_composer_rows[index]
115
+ next if row == previous
116
+
117
+ move_to_screen(top + index, 1)
118
+ @output_io.print(TTY::Cursor.clear_line)
119
+ @output_io.print(row) unless row.to_s.empty?
120
+ end
121
+
122
+ rows.length.upto(rows.length + rows_to_clear - 1) do |index|
123
+ move_to_screen(top + index, 1)
124
+ @output_io.print(TTY::Cursor.clear_line)
125
+ end
126
+
127
+ @last_composer_rows = rows.dup
128
+ end
129
+
130
+ def clear_composer_region_locked(rows_to_clear = nil, height: screen_height)
131
+ rows_to_clear ||= [@reserved_rows, @rendered_rows].max
132
+ clear_bottom_rows_locked(height, rows_to_clear)
133
+ @last_composer_rows = []
134
+ end
135
+
136
+ def resize_prompt_clear_rows(old_width, current_width, old_reserved_rows)
137
+ return old_reserved_rows unless old_reserved_rows.positive?
138
+
139
+ return old_reserved_rows unless current_width < old_width
140
+
141
+ wrapped_rows_per_row = ((old_width - 1) / current_width) + 1
142
+ old_reserved_rows * wrapped_rows_per_row
143
+ end
144
+
145
+ def clear_resized_composer_region_locked(old_height, current_height, rows_to_clear)
146
+ return unless rows_to_clear.positive?
147
+
148
+ old_top = [old_height - rows_to_clear + 1, 1].max
149
+ current_top = [current_height - rows_to_clear + 1, 1].max
150
+ clear_screen_rows_locked([old_top, current_top].min, current_height)
151
+ end
152
+
153
+ def clear_bottom_rows_locked(height, rows_to_clear)
154
+ return unless rows_to_clear.positive?
155
+
156
+ bottom = height
157
+ top = [bottom - rows_to_clear + 1, 1].max
158
+ clear_screen_rows_locked(top, bottom)
159
+ end
160
+
161
+ def clear_screen_rows_locked(top, bottom)
162
+ top.upto(bottom) do |row|
163
+ move_to_screen(row, 1)
164
+ @output_io.print(TTY::Cursor.clear_line)
165
+ end
166
+ end
167
+
168
+ def move_to_screen(row, col)
169
+ @output_io.print("\e[#{row};#{col}H")
170
+ end
171
+
172
+ def screen_size
173
+ [screen_width, screen_height]
174
+ end
175
+
176
+ def screen_width
177
+ [TTY::Screen.width, 1].max
178
+ end
179
+
180
+ def screen_height
181
+ [TTY::Screen.height, 2].max
182
+ end
183
+
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,242 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Selection overlay implementation for list-style prompts.
4
+ class PromptInterface
5
+ # Selection-list overlay support for prompt choices.
6
+ module SelectionPrompt
7
+ private
8
+
9
+ def handle_select_key(key)
10
+ return select_current_choice if key.nil?
11
+ return if handle_select_bracketed_paste_key(key)
12
+
13
+ csi_result = handle_select_csi_u_key(key)
14
+ return csi_result unless csi_result == false
15
+
16
+ if key.is_a?(String) && key.length > 1
17
+ token = next_key_token(key)
18
+ if token.length < key.length
19
+ queue_pending_keys(key[token.length..])
20
+ return handle_select_key(token)
21
+ end
22
+ end
23
+
24
+ key_name = @reader.console.keys[key]
25
+ case key_name
26
+ when :return, :enter
27
+ select_current_choice
28
+ when :backspace
29
+ select_delete_before_cursor
30
+ when :delete
31
+ select_delete_at_cursor
32
+ when :left
33
+ self.composer_cursor -= 1 if composer_cursor.positive?
34
+ when :right
35
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
36
+ when :home
37
+ self.composer_cursor = 0
38
+ when :end
39
+ self.composer_cursor = composer_input.length
40
+ when :up
41
+ select_previous_choice
42
+ when :down
43
+ select_next_choice
44
+ else
45
+ case key
46
+ when "\n", "\r"
47
+ select_current_choice
48
+ when "\b", "\x7F"
49
+ select_delete_before_cursor
50
+ when "\e"
51
+ handle_select_escape_sequence
52
+ else
53
+ select_insert_key(key)
54
+ end
55
+ end
56
+ end
57
+
58
+ def handle_select_csi_u_key(key)
59
+ match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
60
+ return false unless match
61
+
62
+ sequence = match[0]
63
+ code = match[1].to_i
64
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
65
+
66
+ case code
67
+ when 13
68
+ select_current_choice
69
+ when 27
70
+ SELECT_CANCEL
71
+ when 8, 127
72
+ select_delete_before_cursor
73
+ nil
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ def handle_select_escape_sequence
80
+ sequence = read_pending_escape_sequence
81
+ return SELECT_CANCEL if sequence.empty?
82
+
83
+ key_name = @reader.console.keys["\e#{sequence}"]
84
+ case key_name
85
+ when :up
86
+ select_previous_choice
87
+ when :down
88
+ select_next_choice
89
+ when :left
90
+ self.composer_cursor -= 1 if composer_cursor.positive?
91
+ when :right
92
+ self.composer_cursor += 1 if composer_cursor < composer_input.length
93
+ end
94
+ true
95
+ end
96
+
97
+ def handle_select_bracketed_paste_key(key)
98
+ text = key.to_s
99
+ return false unless text.start_with?(BRACKETED_PASTE_START)
100
+
101
+ pasted = text[BRACKETED_PASTE_START.length..] || ""
102
+ until pasted.include?(BRACKETED_PASTE_END)
103
+ chunk = @reader.read_keypress(echo: false, raw: true)
104
+ break if chunk.nil?
105
+
106
+ pasted << chunk.to_s
107
+ end
108
+
109
+ content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
110
+ select_insert_string(normalize_paste(content || ""))
111
+ queue_pending_keys(remaining) if remaining && !remaining.empty?
112
+ true
113
+ end
114
+
115
+ def select_current_choice
116
+ selected_selection_choice || custom_selection_choice || SELECT_CANCEL
117
+ end
118
+
119
+ def custom_selection_choice
120
+ return nil unless @select_state && @select_state[:custom]
121
+
122
+ value = composer_input.strip
123
+ value.empty? ? nil : value
124
+ end
125
+
126
+ def selected_selection_choice
127
+ matches = selection_matches
128
+ return nil if matches.empty?
129
+
130
+ matches[selection_index]
131
+ end
132
+
133
+ def select_previous_choice
134
+ matches = selection_matches
135
+ return if matches.empty?
136
+
137
+ @select_state[:selection_index] = (selection_index - 1) % matches.length
138
+ end
139
+
140
+ def select_next_choice
141
+ matches = selection_matches
142
+ return if matches.empty?
143
+
144
+ @select_state[:selection_index] = (selection_index + 1) % matches.length
145
+ end
146
+
147
+ def select_insert_key(key)
148
+ return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
149
+
150
+ select_insert_string(key)
151
+ end
152
+
153
+ def select_insert_string(string)
154
+ return if string.empty?
155
+
156
+ self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
157
+ self.composer_cursor += string.length
158
+ @select_state[:selection_index] = 0 if @select_state
159
+ end
160
+
161
+ def select_delete_before_cursor
162
+ return unless composer_cursor.positive?
163
+
164
+ self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
165
+ self.composer_cursor -= 1
166
+ @select_state[:selection_index] = 0 if @select_state
167
+ end
168
+
169
+ def select_delete_at_cursor
170
+ return unless composer_cursor < composer_input.length
171
+
172
+ self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
173
+ @select_state[:selection_index] = 0 if @select_state
174
+ end
175
+
176
+ def selection_matches
177
+ choices = @select_state ? @select_state[:choices] : []
178
+ filter = composer_input.downcase.strip
179
+ matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
180
+ clamp_selection_index(matches.length)
181
+ matches
182
+ end
183
+
184
+ def selection_index
185
+ @select_state ? @select_state[:selection_index].to_i : 0
186
+ end
187
+
188
+ def clamp_selection_index(count)
189
+ return unless @select_state
190
+
191
+ @select_state[:selection_index] = 0 if count <= 0
192
+ @select_state[:selection_index] = count - 1 if count.positive? && selection_index >= count
193
+ end
194
+
195
+ def finish_select_prompt
196
+ @mutex.synchronize do
197
+ @select_state = nil
198
+ clear_prompt_locked
199
+ self.composer_input = ""
200
+ self.composer_cursor = 0
201
+ @asking = false
202
+ @rendered_rows = 0
203
+ @cursor_rendered_row = 0
204
+ @output_io.flush
205
+ end
206
+ end
207
+
208
+ def selection_overlay_rows(width, height: screen_height)
209
+ matches = selection_matches
210
+ lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
211
+ if matches.empty?
212
+ if @select_state && @select_state[:custom] && !composer_input.strip.empty?
213
+ lines << overlay_choice_line("Use custom: #{composer_input.strip}", selected: true)
214
+ else
215
+ lines << overlay_text_line("No matches", :muted)
216
+ end
217
+ return overlay_card_rows(selection_overlay_title, lines, width)
218
+ end
219
+
220
+ visible = visible_selection_matches(matches, height: height)
221
+ start_index = visible[:start]
222
+ visible[:choices].each_with_index do |choice, offset|
223
+ index = start_index + offset
224
+ lines << overlay_choice_line(choice, selected: index == selection_index)
225
+ end
226
+ overlay_card_rows(selection_overlay_title, lines, width)
227
+ end
228
+
229
+ def selection_overlay_title
230
+ title = @select_state && @select_state[:title].to_s
231
+ title && !title.empty? ? title : "Sessions"
232
+ end
233
+
234
+ def visible_selection_matches(matches, height: screen_height)
235
+ max_rows = [[height - 7, 1].max, 8].min
236
+ start = [[selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
237
+ { start: start, choices: matches[start, max_rows] || [] }
238
+ end
239
+
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,102 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Slash-command completion overlay behavior.
4
+ class PromptInterface
5
+ # Slash-command completion overlay support.
6
+ module SlashOverlay
7
+ private
8
+
9
+ def reset_slash_selection
10
+ @slash_selection_index = 0
11
+ end
12
+
13
+ def dismiss_slash_overlay
14
+ return false unless slash_overlay_visible?
15
+
16
+ @slash_overlay_dismissed_input = composer_input.dup
17
+ reset_slash_selection
18
+ true
19
+ end
20
+
21
+ def normalize_slash_commands(commands)
22
+ commands.map do |command|
23
+ {
24
+ name: slash_command_value(command, :name).to_s,
25
+ description: slash_command_value(command, :description).to_s,
26
+ argument_hint: slash_command_value(command, :argument_hint).to_s
27
+ }
28
+ end.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
29
+ end
30
+
31
+ def slash_command_value(command, key)
32
+ return command[key] if command.respond_to?(:key?) && command.key?(key)
33
+ return command[key.to_s] if command.respond_to?(:key?) && command.key?(key.to_s)
34
+ return command.public_send(key) if command.respond_to?(key)
35
+
36
+ ""
37
+ end
38
+
39
+ def slash_overlay_visible?
40
+ composer_input.match?(%r{\A/[^\s/]*\z}) && @slash_overlay_dismissed_input != composer_input && !slash_overlay_matches.empty?
41
+ end
42
+
43
+ def slash_overlay_matches
44
+ prefix = composer_input.delete_prefix("/").downcase
45
+ @slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
46
+ end
47
+
48
+ def selected_slash_command
49
+ return nil unless slash_overlay_visible?
50
+
51
+ matches = slash_overlay_matches
52
+ return nil if matches.empty?
53
+
54
+ matches[[@slash_selection_index, matches.length - 1].min]
55
+ end
56
+
57
+ def select_previous_slash_command
58
+ matches = slash_overlay_matches
59
+ return if matches.empty?
60
+
61
+ @slash_selection_index = (@slash_selection_index - 1) % matches.length
62
+ end
63
+
64
+ def select_next_slash_command
65
+ matches = slash_overlay_matches
66
+ return if matches.empty?
67
+
68
+ @slash_selection_index = (@slash_selection_index + 1) % matches.length
69
+ end
70
+
71
+ def complete_selected_slash_command
72
+ command = selected_slash_command
73
+ return false unless command
74
+
75
+ replace_input("/#{command[:name]} ")
76
+ reset_slash_selection
77
+ true
78
+ end
79
+
80
+ def slash_overlay_rows(width, height: screen_height)
81
+ return [] unless slash_overlay_visible?
82
+
83
+ visible = visible_slash_overlay_matches(slash_overlay_matches, height: height)
84
+ start_index = visible[:start]
85
+ lines = visible[:commands].each_with_index.map do |command, offset|
86
+ index = start_index + offset
87
+ hint = command[:argument_hint].empty? ? "" : " #{command[:argument_hint]}"
88
+ description = command[:description].empty? ? "" : " — #{command[:description]}"
89
+ overlay_choice_line("/#{command[:name]}#{hint}#{description}", selected: index == @slash_selection_index)
90
+ end
91
+ overlay_card_rows("Slash commands", lines, width)
92
+ end
93
+
94
+ def visible_slash_overlay_matches(matches, height: screen_height)
95
+ max_rows = [[height - 7, 1].max, 8].min
96
+ start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
97
+ { start: start, commands: matches[start, max_rows] || [] }
98
+ end
99
+
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,65 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Cursor/column state for streamed assistant blocks.
4
+ class PromptInterface
5
+ # State object for streamed assistant output blocks.
6
+ class StreamState
7
+ attr_reader :block, :col
8
+
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ def reset
14
+ @block = nil
15
+ @col = 0
16
+ @pending_wrap = false
17
+ end
18
+
19
+ def start_block(label)
20
+ @block = label
21
+ end
22
+
23
+ def finish_block
24
+ @block = nil
25
+ end
26
+
27
+ def pending_wrap?
28
+ @pending_wrap
29
+ end
30
+
31
+ def reset_position_from_rows(rows, width)
32
+ last_length = rows.empty? ? 0 : ANSI.strip(rows.last).length
33
+ if last_length >= width
34
+ @col = 0
35
+ @pending_wrap = true
36
+ else
37
+ @col = last_length
38
+ @pending_wrap = false
39
+ end
40
+ end
41
+
42
+ def clear_pending_wrap
43
+ @col = 0
44
+ @pending_wrap = false
45
+ end
46
+
47
+ def update_position(text, width:)
48
+ ANSI.strip(text).each_char do |char|
49
+ case char
50
+ when "\n", "\r"
51
+ @col = 0
52
+ @pending_wrap = false
53
+ else
54
+ @pending_wrap = false
55
+ @col += 1
56
+ if @col >= width
57
+ @col = 0
58
+ @pending_wrap = true
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,85 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Bounded text buffer for transcript rendering.
4
+ class PromptInterface
5
+ # Bounded in-memory transcript buffer used by the prompt interface.
6
+ class TranscriptBuffer
7
+ attr_reader :text
8
+
9
+ def initialize(limit:)
10
+ @limit = limit
11
+ @text = +""
12
+ @display_rows_cache_width = nil
13
+ @display_rows_cache_banner_count = nil
14
+ @display_rows_cache = nil
15
+ end
16
+
17
+ def to_s
18
+ @text
19
+ end
20
+
21
+ def include?(*arguments)
22
+ @text.include?(*arguments)
23
+ end
24
+
25
+ def empty?
26
+ @text.empty?
27
+ end
28
+
29
+ def end_with?(*suffixes)
30
+ @text.end_with?(*suffixes)
31
+ end
32
+
33
+ def clear
34
+ @text = +""
35
+ invalidate_display_rows_cache
36
+ end
37
+
38
+ def append(text)
39
+ @text << ANSI.sanitize_transcript(text)
40
+ @text = @text[-@limit, @limit] if @text.length > @limit
41
+ invalidate_display_rows_cache
42
+ @text
43
+ end
44
+
45
+ def viewport_text(row_count, width, visual_banner_count:, banner_rows:)
46
+ viewport_rows(row_count, width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).join("\n")
47
+ end
48
+
49
+ def viewport_rows(row_count, width, visual_banner_count:, banner_rows:)
50
+ return [] unless row_count.positive?
51
+
52
+ rows = display_rows(width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).last(row_count)
53
+ rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
54
+ rows
55
+ end
56
+
57
+ def display_rows(width, visual_banner_count:, banner_rows:)
58
+ if @display_rows_cache_width == width && @display_rows_cache_banner_count == visual_banner_count && @display_rows_cache
59
+ return @display_rows_cache
60
+ end
61
+
62
+ rows = []
63
+ visual_banner_count.times { rows.concat(banner_rows.call(width)) }
64
+ rows << "" if visual_banner_count.positive? && @text.empty?
65
+ rows.concat(text_display_rows(width))
66
+ @display_rows_cache_width = width
67
+ @display_rows_cache_banner_count = visual_banner_count
68
+ @display_rows_cache = rows
69
+ end
70
+
71
+ def text_display_rows(width)
72
+ @text.split(/\r\n|\r|\n/, -1).flat_map do |line|
73
+ chunks = ANSI.wrap_visible(line, width)
74
+ chunks.empty? ? [""] : chunks
75
+ end
76
+ end
77
+
78
+ def invalidate_display_rows_cache
79
+ @display_rows_cache_width = nil
80
+ @display_rows_cache_banner_count = nil
81
+ @display_rows_cache = nil
82
+ end
83
+ end
84
+ end
85
+ end