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
|
@@ -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
|