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,111 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Shared overlay drawing helpers for prompt UI popups.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Renderer for selection, slash-command, and question overlays.
|
|
6
|
+
module OverlayRenderer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def active_overlay_rows(width, height: screen_height)
|
|
10
|
+
return question_overlay_rows(width) if @question_state
|
|
11
|
+
return selection_overlay_rows(width, height: height) if @select_state
|
|
12
|
+
|
|
13
|
+
slash_overlay_rows(width, height: height)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def overlay_card_rows(title, content_rows, width)
|
|
17
|
+
card_width = overlay_card_width(width)
|
|
18
|
+
inner_width = [card_width - 4, 1].max
|
|
19
|
+
rows = [overlay_top_border(title, card_width)]
|
|
20
|
+
rows.concat(content_rows.map { |row| overlay_content_row(row, inner_width) })
|
|
21
|
+
rows << overlay_bottom_border(card_width)
|
|
22
|
+
rows.map { |row| align_overlay_row(row, width) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def overlay_card_width(width)
|
|
26
|
+
return width if width < 32
|
|
27
|
+
return width if @overlay_settings["width"] == "maximum"
|
|
28
|
+
|
|
29
|
+
[[width - 4, 32].max, 96].min
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def overlay_top_border(title, card_width)
|
|
33
|
+
title = visible_truncate(title.to_s, [card_width - 4, 1].max)
|
|
34
|
+
plain_length = ANSI.strip(title).length
|
|
35
|
+
colored("╭", :primary_green) + " #{colored(title, :bright_accent_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def overlay_bottom_border(card_width)
|
|
39
|
+
colored("╰#{"─" * [card_width - 2, 0].max}╯", :primary_green)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def overlay_content_row(row, inner_width)
|
|
43
|
+
text = visible_truncate(row[:text], inner_width)
|
|
44
|
+
text = colored(text, :bright_accent_green, :bold) if row[:selected]
|
|
45
|
+
colored("│", :primary_green) + " " + visible_ljust(text, inner_width) + " " + colored("│", :primary_green)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def overlay_text_line(text, style = nil)
|
|
49
|
+
rendered = case style
|
|
50
|
+
when :bold
|
|
51
|
+
colored(text.to_s, :bold)
|
|
52
|
+
when :muted
|
|
53
|
+
colored(text.to_s, :gray)
|
|
54
|
+
else
|
|
55
|
+
text.to_s
|
|
56
|
+
end
|
|
57
|
+
{ text: rendered }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def overlay_blank_line
|
|
61
|
+
{ text: "" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def overlay_choice_line(text, selected: false)
|
|
65
|
+
{ text: "#{selected ? "›" : " "} #{text}", selected: selected }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def align_overlay_row(row, width)
|
|
69
|
+
plain_length = ANSI.strip(row).length
|
|
70
|
+
padding = [width - plain_length, 0].max
|
|
71
|
+
left = overlay_left_padding(width, plain_length)
|
|
72
|
+
right = padding - left
|
|
73
|
+
(" " * left) + row + (" " * right)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def overlay_left_padding(width, row_width)
|
|
77
|
+
padding = [width - row_width, 0].max
|
|
78
|
+
case @overlay_settings["alignment"]
|
|
79
|
+
when "left"
|
|
80
|
+
0
|
|
81
|
+
when "right"
|
|
82
|
+
padding
|
|
83
|
+
else
|
|
84
|
+
padding / 2
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_overlay_settings(settings)
|
|
89
|
+
values = { "alignment" => "center", "width" => "capped" }
|
|
90
|
+
source = settings.is_a?(Hash) ? settings : {}
|
|
91
|
+
alignment = (source[:alignment] || source["alignment"]).to_s
|
|
92
|
+
width = (source[:width] || source["width"]).to_s
|
|
93
|
+
values["alignment"] = alignment if %w[left center right].include?(alignment)
|
|
94
|
+
values["width"] = width if %w[capped maximum].include?(width)
|
|
95
|
+
values
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def visible_ljust(text, width)
|
|
99
|
+
text.to_s + (" " * [width - ANSI.strip(text.to_s).length, 0].max)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def visible_truncate(text, width)
|
|
103
|
+
plain = ANSI.strip(text.to_s)
|
|
104
|
+
return text.to_s if plain.length <= width
|
|
105
|
+
|
|
106
|
+
plain[0, width]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Prompt label and composer chrome renderer.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Renderer for prompt labels and composer prompt chrome.
|
|
6
|
+
module PromptRenderer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def render_prompt_locked
|
|
10
|
+
return unless @started && @asking
|
|
11
|
+
|
|
12
|
+
handle_resize_locked
|
|
13
|
+
width, height = screen_size
|
|
14
|
+
rows, cursor_row, cursor_col = composer_layout(width, height)
|
|
15
|
+
ensure_scroll_region_locked(rows.length, width: width, height: height)
|
|
16
|
+
@rendered_rows = rows.length
|
|
17
|
+
render_composer_rows_locked(rows, height: height)
|
|
18
|
+
@cursor_rendered_row = cursor_row
|
|
19
|
+
@last_width = width
|
|
20
|
+
@last_height = height
|
|
21
|
+
move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
|
|
22
|
+
render_cursor_visibility_locked
|
|
23
|
+
@output_io.flush
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render_prompt_after_output_locked
|
|
27
|
+
render_prompt_locked
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear_prompt_locked
|
|
31
|
+
handle_resize_locked
|
|
32
|
+
width, height = screen_size
|
|
33
|
+
clear_composer_region_locked(height: height)
|
|
34
|
+
@rendered_rows = 0
|
|
35
|
+
@cursor_rendered_row = 0
|
|
36
|
+
redraw_transcript_locked(width: width, height: height)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear_prompt_for_output_locked
|
|
40
|
+
handle_resize_locked
|
|
41
|
+
width, height = screen_size
|
|
42
|
+
reserve_composer_region_locked(width: width, height: height) if @started && @asking
|
|
43
|
+
clear_composer_region_locked(height: height)
|
|
44
|
+
@rendered_rows = 0
|
|
45
|
+
@cursor_rendered_row = 0
|
|
46
|
+
move_to_transcript_cursor_locked(width: width, height: height) if @started
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prepare_transcript_output_locked
|
|
50
|
+
handle_resize_locked
|
|
51
|
+
width, height = screen_size
|
|
52
|
+
hide_cursor_for_transcript_output_locked
|
|
53
|
+
reserve_composer_region_locked(width: width, height: height)
|
|
54
|
+
move_to_transcript_cursor_locked(width: width, height: height)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def restore_composer_cursor_locked
|
|
58
|
+
return unless @started && @asking
|
|
59
|
+
|
|
60
|
+
width, height = screen_size
|
|
61
|
+
_rows, cursor_row, cursor_col = composer_layout(width, height)
|
|
62
|
+
move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
|
|
63
|
+
render_cursor_visibility_locked
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def redraw_screen_locked(width: screen_width, height: screen_height)
|
|
67
|
+
return unless @started
|
|
68
|
+
|
|
69
|
+
restore_scroll_region_locked
|
|
70
|
+
@output_io.print(TTY::Cursor.clear_screen)
|
|
71
|
+
move_to_screen(1, 1)
|
|
72
|
+
@reserved_rows = 0
|
|
73
|
+
@last_composer_rows = []
|
|
74
|
+
rows, cursor_row, cursor_col = composer_layout(width, height)
|
|
75
|
+
ensure_scroll_region_locked(rows.length, redraw_transcript: false, width: width, height: height)
|
|
76
|
+
redraw_transcript_locked(width: width, height: height)
|
|
77
|
+
@rendered_rows = @asking ? rows.length : 0
|
|
78
|
+
render_composer_rows_locked(rows, height: height) if @asking
|
|
79
|
+
@cursor_rendered_row = @asking ? cursor_row : 0
|
|
80
|
+
@last_width = width
|
|
81
|
+
@last_height = height
|
|
82
|
+
reset_stream_position_from_transcript_locked(width)
|
|
83
|
+
if @asking
|
|
84
|
+
move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
|
|
85
|
+
render_cursor_visibility_locked
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Structured question overlay used by ask_user_question.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Structured question overlay used by the ask-user-question tool.
|
|
6
|
+
module QuestionPrompt
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def ask_single_user_question(question, index, total)
|
|
10
|
+
@mutex.synchronize do
|
|
11
|
+
@prompt_label = "Answer>"
|
|
12
|
+
self.composer_input = ""
|
|
13
|
+
self.composer_cursor = 0
|
|
14
|
+
@pending_keys.clear
|
|
15
|
+
@asking = true
|
|
16
|
+
@busy = false
|
|
17
|
+
@queued_count = 0
|
|
18
|
+
@question_state = {
|
|
19
|
+
question: question[:question] || question["question"],
|
|
20
|
+
header: question[:header] || question["header"],
|
|
21
|
+
options: question[:options] || question["options"],
|
|
22
|
+
selection_index: 0,
|
|
23
|
+
index: index,
|
|
24
|
+
total: total
|
|
25
|
+
}
|
|
26
|
+
reset_history_navigation
|
|
27
|
+
render_prompt_locked
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
key = read_key(nonblock: true)
|
|
32
|
+
result = nil
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
if key.nil?
|
|
35
|
+
resized = handle_resize_locked
|
|
36
|
+
footer_refreshed = tick_footer_locked
|
|
37
|
+
render_prompt_locked if resized || footer_refreshed
|
|
38
|
+
else
|
|
39
|
+
result = handle_question_key(key)
|
|
40
|
+
render_prompt_locked unless result.is_a?(Hash) || result == SELECT_CANCEL
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return result if result.is_a?(Hash) || result == SELECT_CANCEL
|
|
45
|
+
|
|
46
|
+
sleep 0.02 if key.nil?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def begin_question_prompt_state
|
|
51
|
+
{
|
|
52
|
+
prompt_label: @prompt_label,
|
|
53
|
+
input: composer_input,
|
|
54
|
+
cursor: composer_cursor,
|
|
55
|
+
asking: @asking,
|
|
56
|
+
busy: @busy,
|
|
57
|
+
queued_count: @queued_count,
|
|
58
|
+
steered_count: @steered_count,
|
|
59
|
+
pending_keys: @pending_keys.dup,
|
|
60
|
+
select_state: @select_state
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def finish_question_prompt(saved_state)
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@question_state = nil
|
|
67
|
+
@select_state = saved_state[:select_state]
|
|
68
|
+
@prompt_label = saved_state[:prompt_label]
|
|
69
|
+
self.composer_input = saved_state[:input]
|
|
70
|
+
self.composer_cursor = saved_state[:cursor]
|
|
71
|
+
@asking = saved_state[:asking]
|
|
72
|
+
@busy = saved_state[:busy]
|
|
73
|
+
@queued_count = saved_state[:queued_count]
|
|
74
|
+
@steered_count = saved_state[:steered_count]
|
|
75
|
+
@pending_keys = saved_state[:pending_keys]
|
|
76
|
+
render_prompt_locked if @started && @asking
|
|
77
|
+
@output_io.flush
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_question_key(key)
|
|
82
|
+
return if handle_question_bracketed_paste_key(key)
|
|
83
|
+
|
|
84
|
+
csi_result = handle_question_csi_u_key(key)
|
|
85
|
+
return csi_result unless csi_result == false
|
|
86
|
+
|
|
87
|
+
if key.is_a?(String) && key.length > 1
|
|
88
|
+
token = next_key_token(key)
|
|
89
|
+
if token.length < key.length
|
|
90
|
+
queue_pending_keys(key[token.length..])
|
|
91
|
+
return handle_question_key(token)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
key_name = @reader.console.keys[key]
|
|
96
|
+
case key_name
|
|
97
|
+
when :return, :enter
|
|
98
|
+
current_question_answer
|
|
99
|
+
when :backspace
|
|
100
|
+
question_delete_before_cursor
|
|
101
|
+
when :delete
|
|
102
|
+
question_delete_at_cursor
|
|
103
|
+
when :left
|
|
104
|
+
self.composer_cursor -= 1 if composer_cursor.positive?
|
|
105
|
+
when :right
|
|
106
|
+
self.composer_cursor += 1 if composer_cursor < composer_input.length
|
|
107
|
+
when :home
|
|
108
|
+
self.composer_cursor = 0
|
|
109
|
+
when :end
|
|
110
|
+
self.composer_cursor = composer_input.length
|
|
111
|
+
when :up
|
|
112
|
+
question_previous_choice
|
|
113
|
+
when :down
|
|
114
|
+
question_next_choice
|
|
115
|
+
else
|
|
116
|
+
case key
|
|
117
|
+
when "\n", "\r"
|
|
118
|
+
current_question_answer
|
|
119
|
+
when "\b", "\x7F"
|
|
120
|
+
question_delete_before_cursor
|
|
121
|
+
when "\e"
|
|
122
|
+
handle_question_escape_sequence
|
|
123
|
+
else
|
|
124
|
+
question_insert_key(key)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_question_csi_u_key(key)
|
|
130
|
+
match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
|
|
131
|
+
return false unless match
|
|
132
|
+
|
|
133
|
+
sequence = match[0]
|
|
134
|
+
code = match[1].to_i
|
|
135
|
+
queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
|
|
136
|
+
|
|
137
|
+
case code
|
|
138
|
+
when 13
|
|
139
|
+
current_question_answer
|
|
140
|
+
when 27
|
|
141
|
+
SELECT_CANCEL
|
|
142
|
+
when 8, 127
|
|
143
|
+
question_delete_before_cursor
|
|
144
|
+
nil
|
|
145
|
+
else
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def handle_question_escape_sequence
|
|
151
|
+
sequence = read_pending_escape_sequence
|
|
152
|
+
return SELECT_CANCEL if sequence.empty?
|
|
153
|
+
|
|
154
|
+
key_name = @reader.console.keys["\e#{sequence}"]
|
|
155
|
+
case key_name
|
|
156
|
+
when :up
|
|
157
|
+
question_previous_choice
|
|
158
|
+
when :down
|
|
159
|
+
question_next_choice
|
|
160
|
+
when :left
|
|
161
|
+
self.composer_cursor -= 1 if composer_cursor.positive?
|
|
162
|
+
when :right
|
|
163
|
+
self.composer_cursor += 1 if composer_cursor < composer_input.length
|
|
164
|
+
end
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_question_bracketed_paste_key(key)
|
|
169
|
+
text = key.to_s
|
|
170
|
+
return false unless text.start_with?(BRACKETED_PASTE_START)
|
|
171
|
+
|
|
172
|
+
pasted = text[BRACKETED_PASTE_START.length..] || ""
|
|
173
|
+
until pasted.include?(BRACKETED_PASTE_END)
|
|
174
|
+
chunk = @reader.read_keypress(echo: false, raw: true)
|
|
175
|
+
break if chunk.nil?
|
|
176
|
+
|
|
177
|
+
pasted << chunk.to_s
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
|
|
181
|
+
question_insert_string(normalize_paste(content || ""))
|
|
182
|
+
queue_pending_keys(remaining) if remaining && !remaining.empty?
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def current_question_answer
|
|
187
|
+
choice = selected_question_choice
|
|
188
|
+
return nil unless choice
|
|
189
|
+
|
|
190
|
+
if choice[:custom]
|
|
191
|
+
answer = composer_input.strip
|
|
192
|
+
return nil if answer.empty?
|
|
193
|
+
|
|
194
|
+
{ question: current_question_text, answer: answer, custom: true }
|
|
195
|
+
else
|
|
196
|
+
{ question: current_question_text, answer: choice[:label], custom: false }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def selected_question_choice
|
|
201
|
+
choices = question_choices
|
|
202
|
+
return nil if choices.empty?
|
|
203
|
+
|
|
204
|
+
choices[question_selection_index]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def question_choices
|
|
208
|
+
options = Array(@question_state ? @question_state[:options] : []).map do |option|
|
|
209
|
+
{ label: (option[:label] || option["label"]).to_s, description: (option[:description] || option["description"]).to_s }
|
|
210
|
+
end
|
|
211
|
+
choices = options + [{ label: "Type something.", description: composer_input.strip, custom: true }]
|
|
212
|
+
clamp_question_selection_index(choices.length)
|
|
213
|
+
choices
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def current_question_text
|
|
217
|
+
(@question_state && @question_state[:question]).to_s
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def question_selection_index
|
|
221
|
+
@question_state ? @question_state[:selection_index].to_i : 0
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def clamp_question_selection_index(count)
|
|
225
|
+
return unless @question_state
|
|
226
|
+
|
|
227
|
+
@question_state[:selection_index] = 0 if count <= 0
|
|
228
|
+
@question_state[:selection_index] = count - 1 if count.positive? && question_selection_index >= count
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def question_previous_choice
|
|
232
|
+
choices = question_choices
|
|
233
|
+
return if choices.empty?
|
|
234
|
+
|
|
235
|
+
@question_state[:selection_index] = (question_selection_index - 1) % choices.length
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def question_next_choice
|
|
239
|
+
choices = question_choices
|
|
240
|
+
return if choices.empty?
|
|
241
|
+
|
|
242
|
+
@question_state[:selection_index] = (question_selection_index + 1) % choices.length
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def question_insert_key(key)
|
|
246
|
+
return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
|
|
247
|
+
|
|
248
|
+
question_insert_string(key)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def question_insert_string(string)
|
|
252
|
+
return if string.empty?
|
|
253
|
+
|
|
254
|
+
self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
|
|
255
|
+
self.composer_cursor += string.length
|
|
256
|
+
@question_state[:selection_index] = question_choices.length - 1 if @question_state
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def question_delete_before_cursor
|
|
260
|
+
return unless composer_cursor.positive?
|
|
261
|
+
|
|
262
|
+
self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
|
|
263
|
+
self.composer_cursor -= 1
|
|
264
|
+
@question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def question_delete_at_cursor
|
|
268
|
+
return unless composer_cursor < composer_input.length
|
|
269
|
+
|
|
270
|
+
self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
|
|
271
|
+
@question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def question_composer_layout(width, height = screen_height)
|
|
275
|
+
content_width = [width - 4, 1].max
|
|
276
|
+
overlay_rows = active_overlay_rows(width, height: height)
|
|
277
|
+
rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
|
|
278
|
+
return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
|
|
279
|
+
|
|
280
|
+
[rows, overlay_rows.length + 1, 2]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def question_overlay_rows(width)
|
|
284
|
+
title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
|
|
285
|
+
lines = [
|
|
286
|
+
overlay_text_line(@question_state[:question].to_s, :bold),
|
|
287
|
+
overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
|
|
288
|
+
overlay_blank_line
|
|
289
|
+
]
|
|
290
|
+
question_choices.each_with_index do |choice, index|
|
|
291
|
+
selected = index == question_selection_index
|
|
292
|
+
lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
|
|
293
|
+
end
|
|
294
|
+
overlay_card_rows(title, lines, width)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def question_custom_cursor_row
|
|
298
|
+
4 + question_choices.index { |choice| choice[:custom] }.to_i
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def question_custom_cursor_col(width)
|
|
302
|
+
card_width = overlay_card_width(width)
|
|
303
|
+
left_padding = overlay_left_padding(width, card_width)
|
|
304
|
+
custom_prefix = selected_question_choice&.fetch(:custom, false) || !composer_input.empty? ? "Type something: " : "Type something."
|
|
305
|
+
visible_before_cursor = display_question_input(composer_input[0...composer_cursor])
|
|
306
|
+
[[left_padding + 2 + 2 + custom_prefix.length + visible_before_cursor.length, width - 1].min, 0].max
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def choice_text(choice, selected: false)
|
|
310
|
+
if choice[:custom]
|
|
311
|
+
if selected || !composer_input.empty?
|
|
312
|
+
"Type something: #{display_question_input(composer_input)}"
|
|
313
|
+
else
|
|
314
|
+
"Type something."
|
|
315
|
+
end
|
|
316
|
+
else
|
|
317
|
+
description = choice[:description].empty? ? "" : " — #{choice[:description]}"
|
|
318
|
+
"#{choice[:label]}#{description}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def display_question_input(value)
|
|
323
|
+
value.to_s.gsub(/\s+/, " ").strip
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Runtime metadata shown in the prompt footer and status UI.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Runtime footer, model, persona, and session state shown by the prompt interface.
|
|
6
|
+
module RuntimeState
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def reset_spinner_locked
|
|
10
|
+
@spinner_frame_index = 0
|
|
11
|
+
@last_spinner_tick = monotonic_now
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def normalize_busy_activity(activity)
|
|
15
|
+
text = activity.to_s.gsub(/\s+/, " ").strip
|
|
16
|
+
text.empty? ? "streaming" : text
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tick_spinner_locked
|
|
20
|
+
return false unless @busy && @queued_count.zero? && @started && @asking
|
|
21
|
+
|
|
22
|
+
now = monotonic_now
|
|
23
|
+
elapsed = now - @last_spinner_tick
|
|
24
|
+
return false if elapsed < SPINNER_INTERVAL
|
|
25
|
+
|
|
26
|
+
steps = (elapsed / SPINNER_INTERVAL).floor
|
|
27
|
+
@spinner_frame_index = (@spinner_frame_index + steps) % SPINNER_FRAMES.length
|
|
28
|
+
@last_spinner_tick += steps * SPINNER_INTERVAL
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def spinner_frame
|
|
33
|
+
SPINNER_FRAMES[@spinner_frame_index % SPINNER_FRAMES.length]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tick_footer_locked
|
|
37
|
+
return false unless @footer && @started && @asking
|
|
38
|
+
|
|
39
|
+
now = monotonic_now
|
|
40
|
+
elapsed = now - @last_footer_refresh
|
|
41
|
+
return false if elapsed < FOOTER_REFRESH_INTERVAL
|
|
42
|
+
|
|
43
|
+
steps = (elapsed / FOOTER_REFRESH_INTERVAL).floor
|
|
44
|
+
@last_footer_refresh += steps * FOOTER_REFRESH_INTERVAL
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def monotonic_now
|
|
49
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def colored(text, *styles)
|
|
54
|
+
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|