kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -0,0 +1,134 @@
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
+ return nil unless plugin_registry.footer_renderer
56
+
57
+ lambda do
58
+ renderer = plugin_registry.footer_renderer
59
+ next "" unless renderer
60
+
61
+ context = plugin_context(current_footer_conversation, "")
62
+ renderer.call(context).to_s
63
+ rescue StandardError => e
64
+ warn "Warning: Kward plugin footer error: #{e.message}"
65
+ ""
66
+ end
67
+ end
68
+
69
+ def composer_status_text
70
+ conversation = current_footer_conversation
71
+ provider = conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
72
+ model = conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
73
+ reasoning = conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)
74
+ reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
75
+ text = "#{provider} #{model} · #{reasoning}"
76
+ parts = []
77
+ diff = composer_session_diff_text
78
+ parts << diff if diff
79
+ usage = composer_context_usage(provider, model)
80
+ parts << composer_context_percent_text(usage[:percent]) if usage
81
+ parts << text
82
+ parts.join(" · ")
83
+ end
84
+
85
+ def composer_session_diff_text
86
+ return nil if @session_diff.nil? || @session_diff.empty?
87
+
88
+ additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
89
+ deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
90
+ "#{additions}|#{deletions}"
91
+ end
92
+
93
+ def composer_context_percent_text(percent)
94
+ value = percent.round
95
+ color = if value >= 85
96
+ :red
97
+ elsif value >= 50
98
+ :yellow
99
+ end
100
+ ANSI.colorize("#{value}%", color, enabled: @color_enabled)
101
+ end
102
+
103
+ def composer_context_window(provider = nil, model = nil)
104
+ provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
105
+ model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
106
+ ModelInfo.context_window(ModelInfo.provider_label(provider), model)
107
+ end
108
+
109
+ def composer_context_usage(provider, model)
110
+ context_window = composer_context_window(provider, model)
111
+ context_parts = if @client.respond_to?(:current_context_parts)
112
+ @client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas)
113
+ else
114
+ { provider: provider, model: model, messages: current_footer_conversation.context_messages, tools: footer_tool_schemas }
115
+ end
116
+ @context_usage.call(
117
+ provider: provider,
118
+ model: model,
119
+ context_window: context_window,
120
+ context_parts: context_parts
121
+ )
122
+ end
123
+
124
+ def footer_tool_schemas
125
+ @footer_tool_registry&.schemas || []
126
+ end
127
+
128
+ def current_footer_conversation
129
+ @footer_conversation || Conversation.new(system_message: nil)
130
+ end
131
+
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,378 @@
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", :gray, :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
+ end
30
+ when "tool"
31
+ render_tool_message(message, tool_calls_by_id)
32
+ when "compactionSummary"
33
+ render_transcript_block("Compaction summary", message_summary(message))
34
+ else
35
+ render_transcript_block(role.to_s.capitalize, CLITranscriptFormatter.content_text(message_content(message)))
36
+ end
37
+ end
38
+ end
39
+
40
+ def render_reasoning(message)
41
+ reasoning = CLITranscriptFormatter.reasoning(message)
42
+ render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
43
+ end
44
+
45
+ def render_assistant_message(message)
46
+ content = CLITranscriptFormatter.assistant_content_text(message)
47
+ return if content.empty?
48
+
49
+ render_transcript_block("Assistant", content)
50
+ end
51
+
52
+ def render_tool_message(message, tool_calls_by_id)
53
+ tool_call = tool_calls_by_id[message_tool_call_id(message)] || CLITranscriptFormatter.synthetic_tool_call(message_name(message), message_tool_call_id(message))
54
+ render_tool_result(tool_call, message_content(message).to_s)
55
+ end
56
+
57
+ def render_tool_result(tool_call, content)
58
+ summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
59
+ if prompt_interface?
60
+ print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
61
+ else
62
+ @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}\n#{summary}\n")
63
+ end
64
+ end
65
+
66
+ def render_transcript_block(label, content)
67
+ return if content.to_s.empty?
68
+
69
+ rendered = render_markdown_transcript(content)
70
+ if prompt_interface?
71
+ print_block_delta(label, rendered)
72
+ finish_stream_block
73
+ else
74
+ @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}\n#{rendered}\n")
75
+ end
76
+ end
77
+
78
+ def render_markdown_transcript(content)
79
+ ANSI.markdown(content, enabled: @color_enabled)
80
+ end
81
+
82
+ def render_blocking_turn_event(event, markdown_chunks, tool_line_limit: nil, update_diff: false)
83
+ case event
84
+ when Events::ReasoningDelta
85
+ append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
86
+ :streamed
87
+ when Events::AssistantDelta
88
+ append_markdown_delta(markdown_chunks, "Assistant", event.delta)
89
+ :assistant_streamed
90
+ when Events::Retry
91
+ flush_markdown_deltas(markdown_chunks)
92
+ print_retry(event)
93
+ :streamed
94
+ when Events::ToolCall
95
+ flush_markdown_deltas(markdown_chunks)
96
+ :streamed
97
+ when Events::ToolResult
98
+ flush_markdown_deltas(markdown_chunks)
99
+ update_session_diff(event.content, tool_call: event.tool_call) if update_diff
100
+ print_tool_result(event.tool_call, event.content, line_limit: tool_line_limit)
101
+ :streamed
102
+ end
103
+ end
104
+
105
+ def append_markdown_delta(chunks, label, delta)
106
+ text = delta.to_s
107
+ return if text.empty?
108
+
109
+ if chunks.last&.first == label
110
+ chunks.last[1] << text
111
+ else
112
+ chunks << [label, +text]
113
+ end
114
+ end
115
+
116
+ def flush_markdown_deltas(chunks, finish: true, streams: nil)
117
+ wrote = false
118
+ entries = ordered_markdown_entries(chunks.dup)
119
+ if finish && streams
120
+ streamed_labels = entries.map(&:first)
121
+ entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
122
+ end
123
+
124
+ entries.each do |label, content|
125
+ next if content.empty? && !(finish && streams&.key?(label))
126
+
127
+ rendered = if streams
128
+ streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
129
+ streams[label].render(content, final: finish)
130
+ else
131
+ render_markdown_transcript(content)
132
+ end
133
+ streams.delete(label) if finish && streams
134
+ next if rendered.empty?
135
+
136
+ print_block_delta(label, rendered)
137
+ finish_stream_block if finish
138
+ wrote = true
139
+ end
140
+ chunks.clear
141
+ wrote
142
+ end
143
+
144
+ def ordered_markdown_entries(entries)
145
+ labels = entries.map(&:first)
146
+ return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
147
+
148
+ grouped = { "Reasoning" => +"", "Assistant" => +"" }
149
+ others = []
150
+ entries.each do |label, content|
151
+ if grouped.key?(label)
152
+ grouped[label] << content.to_s
153
+ else
154
+ others << [label, content]
155
+ end
156
+ end
157
+
158
+ [["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
159
+ end
160
+
161
+ def message_role(message)
162
+ MessageAccess.role(message)
163
+ end
164
+
165
+ def message_content(message)
166
+ MessageAccess.content(message)
167
+ end
168
+
169
+ def message_summary(message)
170
+ MessageAccess.summary(message) || message_content(message)
171
+ end
172
+
173
+ def message_name(message)
174
+ MessageAccess.name(message)
175
+ end
176
+
177
+ def message_tool_call_id(message)
178
+ MessageAccess.tool_call_id(message)
179
+ end
180
+
181
+ def message_tool_calls(message)
182
+ MessageAccess.tool_calls(message)
183
+ end
184
+
185
+ def tool_call_id(tool_call)
186
+ tool_call["id"] || tool_call[:id]
187
+ end
188
+
189
+ # Writes the user transcript output for the terminal CLI flow.
190
+ def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
191
+ visible_input = display_input.nil? ? input : display_input
192
+ @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
193
+ print_attachment_badges(input, references: attachment_references)
194
+ print_pasted_images(input, image_parts: image_parts)
195
+ end
196
+
197
+ # Writes the attachment badges output for the terminal CLI flow.
198
+ def print_attachment_badges(input, references: nil)
199
+ badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
200
+ return if badges.empty?
201
+
202
+ @prompt.say("#{badges.join("\n")}\n")
203
+ end
204
+
205
+ def composer_attachment_badges(input, attachments = [])
206
+ references = Array(attachments)
207
+ references = Kward::ImageAttachments.references_from_text(input) if references.empty?
208
+ references.map { |reference| attachment_badge_text(reference) }
209
+ end
210
+
211
+ def composer_attachment_parser(input)
212
+ Kward::ImageAttachments.extract_references_from_text(input)
213
+ end
214
+
215
+ def submitted_display_input(input)
216
+ input.respond_to?(:display_input) ? input.display_input : nil
217
+ end
218
+
219
+ def attachment_badge_text(reference)
220
+ status = reference[:status] || reference["status"]
221
+ label = reference[:label] || reference["label"] || "image"
222
+ if status == :missing || status.to_s == "missing"
223
+ "[image?] #{label} not found"
224
+ else
225
+ media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
226
+ size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
227
+ "[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
228
+ end
229
+ end
230
+
231
+ def format_attachment_size(bytes)
232
+ value = bytes.to_i
233
+ return "" unless value.positive?
234
+ return "#{value} B" if value < 1024
235
+
236
+ units = %w[KB MB GB]
237
+ size = value.to_f / 1024
238
+ unit = units.shift
239
+ while size >= 1024 && units.any?
240
+ size /= 1024
241
+ unit = units.shift
242
+ end
243
+ formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
244
+ "#{formatted} #{unit}"
245
+ end
246
+
247
+ # Writes the pasted images output for the terminal CLI flow.
248
+ def print_pasted_images(input, image_parts: nil)
249
+ parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
250
+ parts.each do |part|
251
+ sequence = Kward::ImageAttachments.terminal_image_sequence(part)
252
+ next unless sequence
253
+
254
+ if @prompt.respond_to?(:say_visual)
255
+ @prompt.say_visual(sequence)
256
+ else
257
+ @prompt.say(sequence)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Writes the block delta output for the terminal CLI flow.
263
+ def print_block_delta(label, delta)
264
+ if prompt_interface?
265
+ @prompt.start_stream_block(label)
266
+ @prompt.write_delta(delta)
267
+ else
268
+ start_stream_block(label)
269
+ print delta
270
+ $stdout.flush
271
+ end
272
+ end
273
+
274
+ # Writes the retry output for the terminal CLI flow.
275
+ def print_retry(event)
276
+ message = retry_message(event)
277
+ if prompt_interface?
278
+ if @prompt.respond_to?(:write_stream_block)
279
+ @prompt.write_stream_block("Retry", "#{message}\n", finish: true)
280
+ else
281
+ @prompt.start_stream_block("Retry")
282
+ @prompt.write_delta("#{message}\n")
283
+ @prompt.finish_stream_block
284
+ end
285
+ else
286
+ start_stream_block("Retry")
287
+ puts message
288
+ $stdout.flush
289
+ @stream_block = nil
290
+ end
291
+ end
292
+
293
+ def retry_message(event)
294
+ RetryMessage.format(event)
295
+ end
296
+
297
+ # Writes the tool result output for the terminal CLI flow.
298
+ def print_tool_result(tool_call, content, line_limit: nil)
299
+ summary = tool_result_summary(tool_call, content)
300
+ summary = limit_tool_output_lines(summary, line_limit) if line_limit
301
+ if prompt_interface?
302
+ summary = summary.end_with?("\n") ? summary : "#{summary}\n"
303
+ if @prompt.respond_to?(:write_stream_block)
304
+ @prompt.write_stream_block("Tool", summary, finish: true)
305
+ else
306
+ @prompt.start_stream_block("Tool")
307
+ @prompt.write_delta(summary)
308
+ @prompt.finish_stream_block
309
+ end
310
+ else
311
+ start_stream_block(tool_stream_label(content))
312
+ print summary
313
+ puts unless summary.end_with?("\n")
314
+ $stdout.flush
315
+ @stream_block = nil
316
+ end
317
+ end
318
+
319
+ def start_stream_block(label)
320
+ return if @stream_block == label
321
+
322
+ puts if @stream_block
323
+ puts "\n#{colored("#{transcript_label(label)}>", *label_styles(label))}"
324
+ @stream_block = label
325
+ end
326
+
327
+ def finish_stream_block
328
+ if prompt_interface?
329
+ @prompt.finish_stream_block
330
+ else
331
+ puts if @stream_block
332
+ @stream_block = nil
333
+ end
334
+ end
335
+
336
+ def colored(text, *styles)
337
+ ANSI.colorize(text, *styles, enabled: @color_enabled)
338
+ end
339
+
340
+ def transcript_label(label)
341
+ case label
342
+ when "Assistant"
343
+ assistant_prompt_name
344
+ when "Tool failed"
345
+ "Tool"
346
+ else
347
+ label
348
+ end
349
+ end
350
+
351
+ def label_styles(label)
352
+ case label
353
+ when "Reasoning", "Compaction summary"
354
+ [:gray, :bold]
355
+ when "Assistant", "Kward"
356
+ [:green, :bold]
357
+ when "Tool", "Tool output"
358
+ [:cyan, :bold]
359
+ when "Tool failed"
360
+ [:red, :bold]
361
+ when "Retry"
362
+ [:yellow, :bold]
363
+ else
364
+ [:gray, :bold]
365
+ end
366
+ end
367
+
368
+ def tool_stream_label(content)
369
+ tool_result_failed?(content) ? "Tool failed" : "Tool"
370
+ end
371
+
372
+ def tool_label_styles(content)
373
+ label_styles(tool_stream_label(content))
374
+ end
375
+
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,170 @@
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 runtime_output_prompt
35
+ "Runtime>"
36
+ end
37
+
38
+ def runtime_output(text)
39
+ content = text.to_s.chomp
40
+ label = colored(runtime_output_prompt, :gray, :bold)
41
+ separator = content.include?("\n") ? "\n" : " "
42
+ @prompt.say("\n#{label}#{separator}#{content}\n")
43
+ end
44
+
45
+ def build_interactive_agent(conversation)
46
+ conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
47
+ workspace = configured_workspace(root: conversation.workspace_root)
48
+ tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
49
+ @footer_conversation = conversation
50
+ @footer_tool_registry = tool_registry
51
+ Agent.new(
52
+ client: @client,
53
+ tool_registry: tool_registry,
54
+ conversation: conversation
55
+ )
56
+ end
57
+
58
+ def handle_interactive_shell_command(input, agent)
59
+ command = input.to_s.sub(/\A!\s*/, "")
60
+ if command.strip.empty?
61
+ runtime_output("Shell command is required after !")
62
+ return true
63
+ end
64
+
65
+ run_busy_local_command_and_requeue(activity: "running") do
66
+ result = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
67
+ @prompt.say("\n#{colored("Shell>", :cyan, :bold)} #{command}\n#{result}\n")
68
+ end
69
+ true
70
+ end
71
+
72
+ def shell_command_input?(input)
73
+ input.to_s.start_with?("!")
74
+ end
75
+
76
+ def configured_workspace(root: current_workspace_root)
77
+ Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
78
+ end
79
+
80
+ def workspace_guardrails_enabled?
81
+ ConfigFiles.workspace_guardrails_enabled?(safely_read_config.to_h)
82
+ end
83
+
84
+ def interactive_workspace_root(agent)
85
+ conversation = agent.conversation if agent.respond_to?(:conversation)
86
+ return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
87
+
88
+ current_workspace_root
89
+ end
90
+
91
+ def run_busy_local_command(activity: "loading")
92
+ return yield unless prompt_interface?
93
+
94
+ queued_inputs = []
95
+ result = nil
96
+ error = nil
97
+ @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
98
+
99
+ worker = Thread.new do
100
+ result = yield
101
+ rescue StandardError => e
102
+ error = e
103
+ end
104
+
105
+ while worker.alive?
106
+ collect_queued_input(queued_inputs)
107
+ sleep 0.02
108
+ end
109
+ worker.join
110
+ drain_queued_input(queued_inputs)
111
+ raise error if error
112
+
113
+ [result, queued_inputs]
114
+ ensure
115
+ @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
116
+ end
117
+
118
+ def run_busy_local_command_and_requeue(activity: "loading")
119
+ return yield unless prompt_interface?
120
+
121
+ result, queued_inputs = run_busy_local_command(activity: activity) { yield }
122
+ queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
123
+ result
124
+ end
125
+
126
+ def current_workspace_root
127
+ return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
128
+ return @working_directory if @working_directory
129
+
130
+ Dir.pwd
131
+ end
132
+
133
+ def current_model_provider
134
+ @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
135
+ end
136
+
137
+ def current_model_id
138
+ @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
139
+ end
140
+
141
+ def current_reasoning_effort
142
+ @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
143
+ end
144
+
145
+ def reload_client_config
146
+ @client.reload_config if @client.respond_to?(:reload_config)
147
+ end
148
+
149
+ def refresh_conversation_runtime(conversation)
150
+ return unless conversation&.respond_to?(:update_runtime_context!)
151
+
152
+ conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
153
+ update_assistant_prompt(conversation)
154
+ end
155
+
156
+ def auto_name_active_session(input)
157
+ return unless @active_session
158
+ return unless @active_session.name.to_s.strip.empty?
159
+
160
+ name = default_session_name(input)
161
+ @active_session.rename(name) unless name.empty?
162
+ end
163
+
164
+ def default_session_name(input)
165
+ input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
166
+ end
167
+
168
+ end
169
+ end
170
+ end