kward 0.67.1 → 0.68.0

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