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,132 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Adapter methods that connect the CLI coordinator to the terminal prompt interface.
|
|
6
|
+
module PromptInterfaceSupport
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def setup_interactive_prompt
|
|
10
|
+
return unless @stdin.tty?
|
|
11
|
+
return unless @prompt.is_a?(TTY::Prompt)
|
|
12
|
+
|
|
13
|
+
prompt_interface = load_prompt_interface
|
|
14
|
+
return unless prompt_interface
|
|
15
|
+
|
|
16
|
+
banner_enabled = ConfigFiles.banner_enabled?
|
|
17
|
+
@prompt = prompt_interface.new(
|
|
18
|
+
slash_commands: slash_command_entries,
|
|
19
|
+
overlay_settings: ConfigFiles.overlay_settings,
|
|
20
|
+
footer: prompt_footer_renderer,
|
|
21
|
+
composer_status: method(:composer_status_text),
|
|
22
|
+
busy_help: ConfigFiles.composer_busy_help?,
|
|
23
|
+
attachment_badges: method(:composer_attachment_badges),
|
|
24
|
+
attachment_parser: method(:composer_attachment_parser),
|
|
25
|
+
banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
|
|
26
|
+
banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
|
|
27
|
+
)
|
|
28
|
+
@prompt.start
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load_prompt_interface
|
|
32
|
+
require_relative "../prompt_interface"
|
|
33
|
+
PromptInterface
|
|
34
|
+
rescue LoadError => e
|
|
35
|
+
raise unless missing_tty_tui_load_error?(e)
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def missing_tty_tui_load_error?(error)
|
|
41
|
+
["tty-cursor", "tty-reader", "tty-screen"].include?(error.path) ||
|
|
42
|
+
error.message.match?(/cannot load such file -- tty-(cursor|reader|screen)/)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prompt_interface?
|
|
46
|
+
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Writes the visual banner output for the terminal CLI flow.
|
|
50
|
+
def print_visual_banner
|
|
51
|
+
@prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prompt_footer_renderer
|
|
55
|
+
renderer = plugin_registry.footer_renderer
|
|
56
|
+
return nil unless renderer
|
|
57
|
+
|
|
58
|
+
lambda do
|
|
59
|
+
context = plugin_context(current_footer_conversation, "")
|
|
60
|
+
renderer.call(context).to_s
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
warn "Warning: Kward plugin footer error: #{e.message}"
|
|
63
|
+
""
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def composer_status_text
|
|
68
|
+
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
69
|
+
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
70
|
+
reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
71
|
+
reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
|
|
72
|
+
text = "#{provider} #{model} · #{reasoning}"
|
|
73
|
+
parts = []
|
|
74
|
+
diff = composer_session_diff_text
|
|
75
|
+
parts << diff if diff
|
|
76
|
+
usage = composer_context_usage(provider, model)
|
|
77
|
+
parts << composer_context_percent_text(usage[:percent]) if usage
|
|
78
|
+
parts << text
|
|
79
|
+
parts.join(" · ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def composer_session_diff_text
|
|
83
|
+
return nil if @session_diff.nil? || @session_diff.empty?
|
|
84
|
+
|
|
85
|
+
additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
|
|
86
|
+
deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
|
|
87
|
+
"#{additions}|#{deletions}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def composer_context_percent_text(percent)
|
|
91
|
+
value = percent.round
|
|
92
|
+
color = if value >= 85
|
|
93
|
+
:red
|
|
94
|
+
elsif value >= 50
|
|
95
|
+
:yellow
|
|
96
|
+
end
|
|
97
|
+
ANSI.colorize("#{value}%", color, enabled: @color_enabled)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def composer_context_window
|
|
101
|
+
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
102
|
+
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
103
|
+
provider = ModelInfo.provider_label(provider)
|
|
104
|
+
@client.respond_to?(:current_context_window) ? @client.current_context_window : ModelInfo.context_window(provider, model)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def composer_context_usage(provider, model)
|
|
108
|
+
context_window = composer_context_window
|
|
109
|
+
context_parts = if @client.respond_to?(:current_context_parts)
|
|
110
|
+
@client.current_context_parts(current_footer_conversation.messages, footer_tool_schemas)
|
|
111
|
+
else
|
|
112
|
+
{ provider: provider, model: model, messages: current_footer_conversation.messages, tools: footer_tool_schemas }
|
|
113
|
+
end
|
|
114
|
+
@context_usage.call(
|
|
115
|
+
provider: provider,
|
|
116
|
+
model: model,
|
|
117
|
+
context_window: context_window,
|
|
118
|
+
context_parts: context_parts
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def footer_tool_schemas
|
|
123
|
+
@footer_tool_registry&.schemas || []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def current_footer_conversation
|
|
127
|
+
@footer_conversation || Conversation.new(system_message: nil)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Terminal rendering helpers for streamed assistant/tool output.
|
|
6
|
+
module Rendering
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def render_conversation_transcript(conversation)
|
|
10
|
+
tool_calls_by_id = {}
|
|
11
|
+
@prompt.say("\n#{colored("Transcript", :cyan, :bold)}\n")
|
|
12
|
+
conversation.messages.each do |message|
|
|
13
|
+
role = message_role(message)
|
|
14
|
+
next if role == "system"
|
|
15
|
+
|
|
16
|
+
case role
|
|
17
|
+
when "user"
|
|
18
|
+
print_user_transcript(
|
|
19
|
+
CLITranscriptFormatter.user_transcript_input(message),
|
|
20
|
+
display_input: CLITranscriptFormatter.user_display_text(message),
|
|
21
|
+
attachment_references: CLITranscriptFormatter.image_references(message),
|
|
22
|
+
image_parts: CLITranscriptFormatter.image_parts(message)
|
|
23
|
+
)
|
|
24
|
+
when "assistant"
|
|
25
|
+
render_reasoning(message)
|
|
26
|
+
render_assistant_message(message)
|
|
27
|
+
message_tool_calls(message).each do |tool_call|
|
|
28
|
+
tool_calls_by_id[tool_call_id(tool_call)] = tool_call
|
|
29
|
+
render_tool_call(tool_call)
|
|
30
|
+
end
|
|
31
|
+
when "tool"
|
|
32
|
+
render_tool_message(message, tool_calls_by_id)
|
|
33
|
+
when "compactionSummary"
|
|
34
|
+
render_transcript_block("Compaction summary", message_summary(message))
|
|
35
|
+
else
|
|
36
|
+
render_transcript_block(role.to_s.capitalize, CLITranscriptFormatter.content_text(message_content(message)))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render_reasoning(message)
|
|
42
|
+
reasoning = CLITranscriptFormatter.reasoning(message)
|
|
43
|
+
render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_assistant_message(message)
|
|
47
|
+
content = CLITranscriptFormatter.content_text(message_content(message))
|
|
48
|
+
return if content.empty?
|
|
49
|
+
|
|
50
|
+
render_transcript_block("Assistant", content)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_tool_message(message, tool_calls_by_id)
|
|
54
|
+
tool_call = tool_calls_by_id[message_tool_call_id(message)] || CLITranscriptFormatter.synthetic_tool_call(message_name(message), message_tool_call_id(message))
|
|
55
|
+
render_tool_result(tool_call, message_content(message).to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_tool_call(tool_call)
|
|
59
|
+
if prompt_interface?
|
|
60
|
+
print_tool_call(tool_call)
|
|
61
|
+
else
|
|
62
|
+
@prompt.say("\n#{colored("Tool>", :magenta, :bold)}\n#{tool_command(tool_call)}\n")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_tool_result(tool_call, content)
|
|
67
|
+
summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
68
|
+
if prompt_interface?
|
|
69
|
+
print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
70
|
+
else
|
|
71
|
+
@prompt.say("\n#{colored("Tool output>", :cyan, :bold)}\n#{summary}\n")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_transcript_block(label, content)
|
|
76
|
+
return if content.to_s.empty?
|
|
77
|
+
|
|
78
|
+
rendered = render_markdown_transcript(content)
|
|
79
|
+
if prompt_interface?
|
|
80
|
+
print_block_delta(label, rendered)
|
|
81
|
+
finish_stream_block
|
|
82
|
+
else
|
|
83
|
+
@prompt.say("\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n#{rendered}\n")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_markdown_transcript(content)
|
|
88
|
+
ANSI.markdown(content, enabled: @color_enabled)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_blocking_turn_event(event, markdown_chunks, tool_line_limit: nil, update_diff: false)
|
|
92
|
+
case event
|
|
93
|
+
when Events::ReasoningDelta
|
|
94
|
+
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
95
|
+
:streamed
|
|
96
|
+
when Events::AssistantDelta
|
|
97
|
+
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
98
|
+
:assistant_streamed
|
|
99
|
+
when Events::Retry
|
|
100
|
+
flush_markdown_deltas(markdown_chunks)
|
|
101
|
+
print_retry(event)
|
|
102
|
+
:streamed
|
|
103
|
+
when Events::ToolCall
|
|
104
|
+
flush_markdown_deltas(markdown_chunks)
|
|
105
|
+
print_tool_call(event.tool_call)
|
|
106
|
+
:streamed
|
|
107
|
+
when Events::ToolResult
|
|
108
|
+
flush_markdown_deltas(markdown_chunks)
|
|
109
|
+
update_session_diff(event.content, tool_call: event.tool_call) if update_diff
|
|
110
|
+
print_tool_result(event.tool_call, event.content, line_limit: tool_line_limit)
|
|
111
|
+
:streamed
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def append_markdown_delta(chunks, label, delta)
|
|
116
|
+
text = delta.to_s
|
|
117
|
+
return if text.empty?
|
|
118
|
+
|
|
119
|
+
if chunks.last&.first == label
|
|
120
|
+
chunks.last[1] << text
|
|
121
|
+
else
|
|
122
|
+
chunks << [label, +text]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def flush_markdown_deltas(chunks, finish: true, streams: nil)
|
|
127
|
+
wrote = false
|
|
128
|
+
entries = ordered_markdown_entries(chunks.dup)
|
|
129
|
+
if finish && streams
|
|
130
|
+
streamed_labels = entries.map(&:first)
|
|
131
|
+
entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
entries.each do |label, content|
|
|
135
|
+
next if content.empty? && !(finish && streams&.key?(label))
|
|
136
|
+
|
|
137
|
+
rendered = if streams
|
|
138
|
+
streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
|
|
139
|
+
streams[label].render(content, final: finish)
|
|
140
|
+
else
|
|
141
|
+
render_markdown_transcript(content)
|
|
142
|
+
end
|
|
143
|
+
streams.delete(label) if finish && streams
|
|
144
|
+
next if rendered.empty?
|
|
145
|
+
|
|
146
|
+
print_block_delta(label, rendered)
|
|
147
|
+
finish_stream_block if finish
|
|
148
|
+
wrote = true
|
|
149
|
+
end
|
|
150
|
+
chunks.clear
|
|
151
|
+
wrote
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ordered_markdown_entries(entries)
|
|
155
|
+
labels = entries.map(&:first)
|
|
156
|
+
return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
|
|
157
|
+
|
|
158
|
+
grouped = { "Reasoning" => +"", "Assistant" => +"" }
|
|
159
|
+
others = []
|
|
160
|
+
entries.each do |label, content|
|
|
161
|
+
if grouped.key?(label)
|
|
162
|
+
grouped[label] << content.to_s
|
|
163
|
+
else
|
|
164
|
+
others << [label, content]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def message_role(message)
|
|
172
|
+
MessageAccess.role(message)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def message_content(message)
|
|
176
|
+
MessageAccess.content(message)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def message_summary(message)
|
|
180
|
+
MessageAccess.summary(message) || message_content(message)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def message_name(message)
|
|
184
|
+
MessageAccess.name(message)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def message_tool_call_id(message)
|
|
188
|
+
MessageAccess.tool_call_id(message)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def message_tool_calls(message)
|
|
192
|
+
MessageAccess.tool_calls(message)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def tool_call_id(tool_call)
|
|
196
|
+
tool_call["id"] || tool_call[:id]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Writes the user transcript output for the terminal CLI flow.
|
|
200
|
+
def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
|
|
201
|
+
visible_input = display_input.nil? ? input : display_input
|
|
202
|
+
@prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
|
|
203
|
+
print_attachment_badges(input, references: attachment_references)
|
|
204
|
+
print_pasted_images(input, image_parts: image_parts)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Writes the attachment badges output for the terminal CLI flow.
|
|
208
|
+
def print_attachment_badges(input, references: nil)
|
|
209
|
+
badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
|
|
210
|
+
return if badges.empty?
|
|
211
|
+
|
|
212
|
+
@prompt.say("#{badges.join("\n")}\n")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def composer_attachment_badges(input, attachments = [])
|
|
216
|
+
references = Array(attachments)
|
|
217
|
+
references = Kward::ImageAttachments.references_from_text(input) if references.empty?
|
|
218
|
+
references.map { |reference| attachment_badge_text(reference) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def composer_attachment_parser(input)
|
|
222
|
+
Kward::ImageAttachments.extract_references_from_text(input)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def submitted_display_input(input)
|
|
226
|
+
input.respond_to?(:display_input) ? input.display_input : nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def attachment_badge_text(reference)
|
|
230
|
+
status = reference[:status] || reference["status"]
|
|
231
|
+
label = reference[:label] || reference["label"] || "image"
|
|
232
|
+
if status == :missing || status.to_s == "missing"
|
|
233
|
+
"[image?] #{label} not found"
|
|
234
|
+
else
|
|
235
|
+
media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
|
|
236
|
+
size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
|
|
237
|
+
"[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_attachment_size(bytes)
|
|
242
|
+
value = bytes.to_i
|
|
243
|
+
return "" unless value.positive?
|
|
244
|
+
return "#{value} B" if value < 1024
|
|
245
|
+
|
|
246
|
+
units = %w[KB MB GB]
|
|
247
|
+
size = value.to_f / 1024
|
|
248
|
+
unit = units.shift
|
|
249
|
+
while size >= 1024 && units.any?
|
|
250
|
+
size /= 1024
|
|
251
|
+
unit = units.shift
|
|
252
|
+
end
|
|
253
|
+
formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
|
|
254
|
+
"#{formatted} #{unit}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Writes the pasted images output for the terminal CLI flow.
|
|
258
|
+
def print_pasted_images(input, image_parts: nil)
|
|
259
|
+
parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
|
|
260
|
+
parts.each do |part|
|
|
261
|
+
sequence = Kward::ImageAttachments.terminal_image_sequence(part)
|
|
262
|
+
next unless sequence
|
|
263
|
+
|
|
264
|
+
if @prompt.respond_to?(:say_visual)
|
|
265
|
+
@prompt.say_visual(sequence)
|
|
266
|
+
else
|
|
267
|
+
@prompt.say(sequence)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Writes the block delta output for the terminal CLI flow.
|
|
273
|
+
def print_block_delta(label, delta)
|
|
274
|
+
if prompt_interface?
|
|
275
|
+
@prompt.start_stream_block(label)
|
|
276
|
+
@prompt.write_delta(delta)
|
|
277
|
+
else
|
|
278
|
+
start_stream_block(label)
|
|
279
|
+
print delta
|
|
280
|
+
$stdout.flush
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Writes the retry output for the terminal CLI flow.
|
|
285
|
+
def print_retry(event)
|
|
286
|
+
message = retry_message(event)
|
|
287
|
+
if prompt_interface?
|
|
288
|
+
if @prompt.respond_to?(:write_stream_block)
|
|
289
|
+
@prompt.write_stream_block("Retry", "#{message}\n", finish: true)
|
|
290
|
+
else
|
|
291
|
+
@prompt.start_stream_block("Retry")
|
|
292
|
+
@prompt.write_delta("#{message}\n")
|
|
293
|
+
@prompt.finish_stream_block
|
|
294
|
+
end
|
|
295
|
+
else
|
|
296
|
+
start_stream_block("Retry")
|
|
297
|
+
puts message
|
|
298
|
+
$stdout.flush
|
|
299
|
+
@stream_block = nil
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def retry_message(event)
|
|
304
|
+
RetryMessage.format(event)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Writes the tool call output for the terminal CLI flow.
|
|
308
|
+
def print_tool_call(tool_call)
|
|
309
|
+
if prompt_interface?
|
|
310
|
+
if @prompt.respond_to?(:write_stream_block)
|
|
311
|
+
@prompt.write_stream_block("Tool", "#{tool_command(tool_call)}\n", finish: true)
|
|
312
|
+
else
|
|
313
|
+
@prompt.start_stream_block("Tool")
|
|
314
|
+
@prompt.write_delta("#{tool_command(tool_call)}\n")
|
|
315
|
+
@prompt.finish_stream_block
|
|
316
|
+
end
|
|
317
|
+
else
|
|
318
|
+
start_stream_block("Tool")
|
|
319
|
+
puts tool_command(tool_call)
|
|
320
|
+
$stdout.flush
|
|
321
|
+
@stream_block = nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Writes the tool result output for the terminal CLI flow.
|
|
326
|
+
def print_tool_result(tool_call, content, line_limit: nil)
|
|
327
|
+
summary = tool_result_summary(tool_call, content)
|
|
328
|
+
summary = limit_tool_output_lines(summary, line_limit) if line_limit
|
|
329
|
+
if prompt_interface?
|
|
330
|
+
summary = summary.end_with?("\n") ? summary : "#{summary}\n"
|
|
331
|
+
if @prompt.respond_to?(:write_stream_block)
|
|
332
|
+
@prompt.write_stream_block("Tool output", summary, finish: true)
|
|
333
|
+
else
|
|
334
|
+
@prompt.start_stream_block("Tool output")
|
|
335
|
+
@prompt.write_delta(summary)
|
|
336
|
+
@prompt.finish_stream_block
|
|
337
|
+
end
|
|
338
|
+
else
|
|
339
|
+
start_stream_block("Tool output")
|
|
340
|
+
print summary
|
|
341
|
+
puts unless summary.end_with?("\n")
|
|
342
|
+
$stdout.flush
|
|
343
|
+
@stream_block = nil
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def start_stream_block(label)
|
|
348
|
+
return if @stream_block == label
|
|
349
|
+
|
|
350
|
+
puts if @stream_block
|
|
351
|
+
puts "\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}"
|
|
352
|
+
@stream_block = label
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def finish_stream_block
|
|
356
|
+
if prompt_interface?
|
|
357
|
+
@prompt.finish_stream_block
|
|
358
|
+
else
|
|
359
|
+
puts if @stream_block
|
|
360
|
+
@stream_block = nil
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def colored(text, *styles)
|
|
365
|
+
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def transcript_label(label)
|
|
369
|
+
label == "Assistant" ? assistant_prompt_name : label
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def label_color(label)
|
|
373
|
+
case label
|
|
374
|
+
when "Reasoning"
|
|
375
|
+
:yellow
|
|
376
|
+
when "Assistant", "Kward"
|
|
377
|
+
:green
|
|
378
|
+
when "Tool"
|
|
379
|
+
:magenta
|
|
380
|
+
when "Tool output"
|
|
381
|
+
:cyan
|
|
382
|
+
else
|
|
383
|
+
:blue
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Shared runtime construction helpers for CLI conversations, workspaces, plugins, and sessions.
|
|
6
|
+
module RuntimeHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def new_conversation(workspace_root: current_workspace_root)
|
|
10
|
+
Conversation.new(workspace_root: workspace_root, provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update_assistant_prompt(conversation)
|
|
14
|
+
@assistant_prompt = assistant_prompt_label(conversation)
|
|
15
|
+
@prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
|
|
16
|
+
@assistant_prompt
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def assistant_prompt_label(conversation)
|
|
20
|
+
label = ConfigFiles.active_persona_label(workspace_root: conversation.workspace_root, model: conversation.model)
|
|
21
|
+
"#{label || "Assistant"}>"
|
|
22
|
+
rescue StandardError
|
|
23
|
+
"Assistant>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assistant_prompt_name
|
|
27
|
+
assistant_output_prompt.delete_suffix(">")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assistant_output_prompt
|
|
31
|
+
@assistant_prompt || "Assistant>"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_interactive_agent(conversation)
|
|
35
|
+
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
36
|
+
workspace = configured_workspace(root: conversation.workspace_root)
|
|
37
|
+
tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
|
|
38
|
+
@footer_conversation = conversation
|
|
39
|
+
@footer_tool_registry = tool_registry
|
|
40
|
+
Agent.new(
|
|
41
|
+
client: @client,
|
|
42
|
+
tool_registry: tool_registry,
|
|
43
|
+
conversation: conversation
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle_interactive_shell_command(input, agent)
|
|
48
|
+
command = input.to_s.sub(/\A!\s*/, "")
|
|
49
|
+
if command.strip.empty?
|
|
50
|
+
@prompt.say("\nShell command is required after !\n")
|
|
51
|
+
return true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
run_busy_local_command_and_requeue(activity: "running") do
|
|
55
|
+
result = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
|
|
56
|
+
@prompt.say("\n#{colored("Shell>", :green, :bold)} #{command}\n#{result}\n")
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def shell_command_input?(input)
|
|
62
|
+
input.to_s.start_with?("!")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def configured_workspace(root: current_workspace_root)
|
|
66
|
+
Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def workspace_guardrails_enabled?
|
|
70
|
+
ConfigFiles.workspace_guardrails_enabled?(safely_read_config.to_h)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def interactive_workspace_root(agent)
|
|
74
|
+
conversation = agent.conversation if agent.respond_to?(:conversation)
|
|
75
|
+
return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
|
|
76
|
+
|
|
77
|
+
current_workspace_root
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_busy_local_command(activity: "loading")
|
|
81
|
+
return yield unless prompt_interface?
|
|
82
|
+
|
|
83
|
+
queued_inputs = []
|
|
84
|
+
result = nil
|
|
85
|
+
error = nil
|
|
86
|
+
@prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
|
|
87
|
+
|
|
88
|
+
worker = Thread.new do
|
|
89
|
+
result = yield
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
error = e
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
while worker.alive?
|
|
95
|
+
collect_queued_input(queued_inputs)
|
|
96
|
+
sleep 0.02
|
|
97
|
+
end
|
|
98
|
+
worker.join
|
|
99
|
+
drain_queued_input(queued_inputs)
|
|
100
|
+
raise error if error
|
|
101
|
+
|
|
102
|
+
[result, queued_inputs]
|
|
103
|
+
ensure
|
|
104
|
+
@prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_busy_local_command_and_requeue(activity: "loading")
|
|
108
|
+
return yield unless prompt_interface?
|
|
109
|
+
|
|
110
|
+
result, queued_inputs = run_busy_local_command(activity: activity) { yield }
|
|
111
|
+
queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def current_workspace_root
|
|
116
|
+
return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
|
|
117
|
+
return @working_directory if @working_directory
|
|
118
|
+
|
|
119
|
+
Dir.pwd
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def current_model_provider
|
|
123
|
+
@client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def current_model_id
|
|
127
|
+
@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def current_reasoning_effort
|
|
131
|
+
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def reload_client_config
|
|
135
|
+
@client.reload_config if @client.respond_to?(:reload_config)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def refresh_conversation_runtime(conversation)
|
|
139
|
+
return unless conversation&.respond_to?(:update_runtime_context!)
|
|
140
|
+
|
|
141
|
+
conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
142
|
+
update_assistant_prompt(conversation)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def auto_name_active_session(input)
|
|
146
|
+
return unless @active_session
|
|
147
|
+
return unless @active_session.name.to_s.strip.empty?
|
|
148
|
+
|
|
149
|
+
name = default_session_name(input)
|
|
150
|
+
@active_session.rename(name) unless name.empty?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def default_session_name(input)
|
|
154
|
+
input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|