kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
data/lib/kward/cli.rb ADDED
@@ -0,0 +1,2122 @@
1
+ require "base64"
2
+ require "json"
3
+ require "thread"
4
+ require "tty-prompt"
5
+ require_relative "agent"
6
+ require_relative "ansi"
7
+ require_relative "model/client"
8
+ require_relative "compactor"
9
+ require_relative "config_files"
10
+ require_relative "clipboard"
11
+ require_relative "model/context_usage"
12
+ require_relative "events"
13
+ require_relative "export_path"
14
+ require_relative "auth/github_oauth"
15
+ require_relative "auth/openrouter_api_key"
16
+ require_relative "image_attachments"
17
+ require_relative "memory/manager"
18
+ require_relative "transcript_export"
19
+ require_relative "message_access"
20
+ require_relative "model/model_info"
21
+ require_relative "auth/openai_oauth"
22
+ require_relative "pan/server"
23
+ require_relative "plugin_registry"
24
+ require_relative "prompts/commands"
25
+ require_relative "model/retry_message"
26
+ require_relative "rpc/server"
27
+ require_relative "session_diff"
28
+ require_relative "session_store"
29
+ require_relative "starter_pack_installer"
30
+ require_relative "steering"
31
+ require_relative "tools/tool_call"
32
+ require_relative "tools/registry"
33
+ require_relative "telemetry/stats"
34
+ require_relative "workspace"
35
+
36
+ module Kward
37
+ # Command-line interface for interactive chat, one-shot prompts, login,
38
+ # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
39
+ class CLI
40
+ # Order from the captain: The next line shall never be deleted.
41
+ STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
42
+ RESTORED_TOOL_OUTPUT_LIMIT = 2_000
43
+ INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
44
+ STREAM_RENDER_INTERVAL = 0.025
45
+ INTERACTIVE_EVENT_DRAIN_LIMIT = 100
46
+ BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
47
+ BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
48
+
49
+ def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
50
+ @argv = argv
51
+ @stdin = stdin
52
+ @prompt = prompt
53
+ @client = client
54
+ @session_store = session_store
55
+ @context_usage = context_usage
56
+ @active_session = nil
57
+ @session_diff = SessionDiff.new
58
+ @cleanup_sessions = []
59
+ @plugin_registry = nil
60
+ @color_enabled = ANSI.enabled?($stdout)
61
+ end
62
+
63
+ # Dispatches command-line modes, including RPC, login, stats export, Pan
64
+ # mode, one-shot prompts, and interactive chat.
65
+ #
66
+ # @return [void]
67
+ def run
68
+ ConfigFiles.ensure_default_config!
69
+
70
+ if @argv == ["--install-starter-pack"]
71
+ install_starter_pack
72
+ return
73
+ end
74
+
75
+ if @argv.first == "rpc" && @argv.length == 1
76
+ Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
77
+ return
78
+ end
79
+
80
+ if @argv[0, 2] == ["stats", "tokens"]
81
+ export_token_stats(@argv[2..] || [])
82
+ return
83
+ end
84
+
85
+ if pan_mode?
86
+ PanServer.new(client: @client, working_directory: pan_working_directory).run
87
+ return
88
+ end
89
+
90
+ if ["login", "--login"].include?(@argv.first) && @argv.length <= 2
91
+ login(provider: @argv[1])
92
+ return
93
+ end
94
+
95
+ first_prompt = @argv.join(" ").strip
96
+ unless first_prompt.empty?
97
+ answer = one_shot(first_prompt)
98
+ puts answer unless answer.empty?
99
+ return
100
+ end
101
+
102
+ stdin_prompt = piped_prompt
103
+ unless stdin_prompt.empty?
104
+ answer = one_shot(stdin_prompt)
105
+ puts answer unless answer.empty?
106
+ return
107
+ end
108
+
109
+ interactive_loop
110
+ end
111
+
112
+ def one_shot(input)
113
+ streamed = false
114
+ assistant_streamed = false
115
+ markdown_chunks = []
116
+ conversation = new_conversation
117
+ agent = Agent.new(
118
+ client: @client,
119
+ tool_registry: ToolRegistry.new(prompt: @prompt),
120
+ conversation: conversation
121
+ )
122
+ answer = agent.ask(input) do |event|
123
+ case event
124
+ when Events::ReasoningDelta
125
+ streamed = true
126
+ append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
127
+ when Events::AssistantDelta
128
+ streamed = true
129
+ assistant_streamed = true
130
+ append_markdown_delta(markdown_chunks, "Assistant", event.delta)
131
+ when Events::Retry
132
+ streamed = true
133
+ flush_markdown_deltas(markdown_chunks)
134
+ print_retry(event)
135
+ when Events::ToolCall
136
+ streamed = true
137
+ flush_markdown_deltas(markdown_chunks)
138
+ print_tool_call(event.tool_call)
139
+ when Events::ToolResult
140
+ streamed = true
141
+ flush_markdown_deltas(markdown_chunks)
142
+ print_tool_result(event.tool_call, event.content)
143
+ end
144
+ end
145
+ flush_markdown_deltas(markdown_chunks) if streamed
146
+ assistant_streamed ? "" : render_markdown_transcript(answer)
147
+ end
148
+
149
+ def login(provider: nil, oauth: nil)
150
+ provider = provider.to_s.downcase
151
+ if provider == "openrouter"
152
+ auth = oauth || OpenRouterAPIKey.new
153
+ path = auth.login(prompt: @prompt)
154
+ @prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
155
+ return
156
+ end
157
+
158
+ oauth ||= provider == "github" ? GithubOAuth.new : OpenAIOAuth.new
159
+ path = oauth.login(prompt: @prompt)
160
+ name = provider == "github" ? "GitHub" : "OpenAI"
161
+ @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
162
+ end
163
+
164
+ def interactive_loop(agent: nil)
165
+ setup_interactive_prompt
166
+ session_store = interactive_session_store(agent)
167
+ if session_store && agent.nil?
168
+ @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
169
+ reset_session_diff
170
+ conversation = new_conversation(workspace_root: session_store.cwd)
171
+ @active_session.attach(conversation)
172
+ agent = build_interactive_agent(conversation)
173
+ elsif session_store
174
+ @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
175
+ reset_session_diff
176
+ @active_session.attach(agent.conversation)
177
+ else
178
+ agent ||= build_interactive_agent(new_conversation)
179
+ end
180
+
181
+ update_assistant_prompt(agent.conversation)
182
+ @footer_conversation = agent.conversation
183
+
184
+ print_visual_banner
185
+
186
+ @pending_inputs = []
187
+
188
+ loop do
189
+ input = @pending_inputs.shift || @prompt.ask("You>")
190
+ break if input.nil?
191
+
192
+ display_input = submitted_display_input(input)
193
+ command_input = display_input.nil? ? input : display_input
194
+ command = command_input.strip
195
+ next if command.empty? && input.strip.empty?
196
+ if command.empty?
197
+ handled = false
198
+ else
199
+ selected_input = selected_slash_command_input(command_input)
200
+ if selected_input
201
+ input = selected_input
202
+ command = input.strip
203
+ display_input = input if display_input
204
+ end
205
+ break if ["/exit", "/quit"].include?(command)
206
+ handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
207
+ agent = replacement_agent if replacement_agent
208
+ end
209
+ next if handled
210
+
211
+ expanded_input = expand_prompt_template(input)
212
+ display_input = display_input || input if expanded_input
213
+ input = expanded_input || input
214
+ @footer_conversation = agent.conversation
215
+ begin
216
+ pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
217
+ pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
218
+ rescue StandardError => e
219
+ @prompt.say("\nError: #{e.message}\n")
220
+ end
221
+ end
222
+
223
+ agent.conversation
224
+ rescue Interrupt
225
+ @prompt.say("\nGoodbye.")
226
+ agent&.conversation
227
+ ensure
228
+ begin
229
+ @prompt.close if prompt_interface?
230
+ ensure
231
+ cleanup_unused_sessions
232
+ end
233
+ end
234
+
235
+ def piped_prompt
236
+ return "" if @stdin.tty?
237
+
238
+ @stdin.read.strip
239
+ end
240
+
241
+ private
242
+
243
+ def install_starter_pack
244
+ result = StarterPackInstaller.install
245
+ installed_count = result.installed.length
246
+ skipped_count = result.skipped.length
247
+ @prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
248
+ @prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
249
+ rescue StandardError => e
250
+ warn "Failed to install starter pack: #{e.message}"
251
+ exit 1
252
+ end
253
+
254
+ def pan_mode?
255
+ @argv.include?("--pan-mode")
256
+ end
257
+
258
+ def export_token_stats(arguments)
259
+ options = parse_token_stats_options(arguments)
260
+ csv = TelemetryStats.new.token_usage_csv(options[:range], bucket: options[:bucket])
261
+ if options[:output]
262
+ File.write(options[:output], csv)
263
+ else
264
+ $stdout.write(csv)
265
+ end
266
+ rescue ArgumentError => e
267
+ warn e.message
268
+ warn "Usage: kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]"
269
+ exit 1
270
+ end
271
+
272
+ def parse_token_stats_options(arguments)
273
+ remaining = []
274
+ bucket = nil
275
+ output = nil
276
+ index = 0
277
+ while index < arguments.length
278
+ argument = arguments[index]
279
+ case argument
280
+ when "--bucket"
281
+ index += 1
282
+ raise ArgumentError, "Missing value for --bucket" if index >= arguments.length
283
+
284
+ bucket = arguments[index]
285
+ when /\A--bucket=(.+)\z/
286
+ bucket = Regexp.last_match(1)
287
+ when "--output"
288
+ index += 1
289
+ raise ArgumentError, "Missing value for --output" if index >= arguments.length
290
+
291
+ output = arguments[index]
292
+ when /\A--output=(.+)\z/
293
+ output = Regexp.last_match(1)
294
+ else
295
+ remaining << argument
296
+ end
297
+ index += 1
298
+ end
299
+ { range: remaining.join(" "), bucket: bucket, output: output }
300
+ end
301
+
302
+ def pan_working_directory
303
+ value = option_value("--working-directory")
304
+ value.to_s.strip.empty? ? Dir.pwd : value
305
+ end
306
+
307
+ def option_value(name)
308
+ @argv.each_with_index do |argument, index|
309
+ return argument.split("=", 2).last if argument.start_with?("#{name}=")
310
+ return @argv[index + 1] if argument == name
311
+ end
312
+ nil
313
+ end
314
+
315
+ def interactive_session_store(agent)
316
+ return @session_store if @session_store
317
+ return nil if agent
318
+
319
+ SessionStore.new
320
+ end
321
+
322
+ def track_session(session)
323
+ @cleanup_sessions << session if session
324
+ session
325
+ end
326
+
327
+ def reset_session_diff(path = nil)
328
+ @session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
329
+ end
330
+
331
+ def update_session_diff(content)
332
+ return unless @session_diff&.add_tool_result(content)
333
+
334
+ @prompt.redraw if @prompt.respond_to?(:redraw)
335
+ end
336
+
337
+ def cleanup_unused_sessions
338
+ @cleanup_sessions.reverse_each do |session|
339
+ session.delete_if_unused if session.respond_to?(:delete_if_unused)
340
+ end
341
+ @cleanup_sessions.clear
342
+ end
343
+
344
+ def cleanup_replaced_session(previous_session)
345
+ return unless previous_session
346
+ return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
347
+
348
+ previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
349
+ end
350
+
351
+ def new_conversation(workspace_root: Dir.pwd)
352
+ Conversation.new(workspace_root: workspace_root, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
353
+ end
354
+
355
+ def update_assistant_prompt(conversation)
356
+ @assistant_prompt = assistant_prompt_label(conversation)
357
+ @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
358
+ @assistant_prompt
359
+ end
360
+
361
+ def assistant_prompt_label(conversation)
362
+ label = ConfigFiles.active_persona_label(workspace_root: conversation.workspace_root, model: conversation.model)
363
+ "#{label || "Assistant"}>"
364
+ rescue StandardError
365
+ "Assistant>"
366
+ end
367
+
368
+ def assistant_prompt_name
369
+ assistant_output_prompt.delete_suffix(">")
370
+ end
371
+
372
+ def assistant_output_prompt
373
+ @assistant_prompt || "Assistant>"
374
+ end
375
+
376
+ def build_interactive_agent(conversation)
377
+ conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
378
+ workspace = Workspace.new(root: conversation.workspace_root)
379
+ tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
380
+ @footer_conversation = conversation
381
+ @footer_tool_registry = tool_registry
382
+ Agent.new(
383
+ client: @client,
384
+ tool_registry: tool_registry,
385
+ conversation: conversation
386
+ )
387
+ end
388
+
389
+ def handle_local_slash_command(command, agent, session_store)
390
+ name, argument = parse_slash_command(command)
391
+ case name
392
+ when "status"
393
+ run_busy_local_command_and_requeue { print_status }
394
+ [true, nil]
395
+ when "stats"
396
+ run_busy_local_command_and_requeue { print_stats(argument) }
397
+ [true, nil]
398
+ when "crew"
399
+ @prompt.say("\nThe /crew command is not implemented yet.\n")
400
+ [true, nil]
401
+ when "memory"
402
+ activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
403
+ run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
404
+ [true, nil]
405
+ when "redraw"
406
+ run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
407
+ [true, nil]
408
+ when "settings"
409
+ configure_settings
410
+ [true, nil]
411
+ when "login"
412
+ login_interactively
413
+ [true, nil]
414
+ when "model"
415
+ models = run_busy_local_command_and_requeue { normalized_available_models }
416
+ configure_model(agent.conversation, models: models)
417
+ [true, nil]
418
+ when "openrouter/catalog"
419
+ run_busy_local_command_and_requeue { print_openrouter_catalog }
420
+ [true, nil]
421
+ when "reasoning"
422
+ configure_reasoning(agent.conversation)
423
+ [true, nil]
424
+ when "new"
425
+ [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
426
+ when "resume"
427
+ [true, run_busy_local_command_and_requeue do
428
+ path = argument.to_s.strip
429
+ path = select_session_path(session_store) if session_store && path.empty?
430
+ resume_session(session_store, path)
431
+ end]
432
+ when "name"
433
+ run_busy_local_command_and_requeue { rename_session(argument) }
434
+ [true, nil]
435
+ when "clone"
436
+ [true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
437
+ when "copy"
438
+ run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
439
+ [true, nil]
440
+ when "export"
441
+ run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
442
+ [true, nil]
443
+ when "compact"
444
+ run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
445
+ [true, nil]
446
+ else
447
+ return run_plugin_command(name, argument, agent) if plugin_command_for(name)
448
+
449
+ [false, nil]
450
+ end
451
+ end
452
+
453
+ def parse_slash_command(command)
454
+ PromptCommands.parse(command) || [nil, ""]
455
+ end
456
+
457
+ def memory_summarize_command?(argument)
458
+ subcommand, = argument.to_s.strip.split(/\s+/, 2)
459
+ ["summarize", "learn"].include?(subcommand)
460
+ end
461
+
462
+ def print_status
463
+ lines = [STATUS_MESSAGE]
464
+ lines << ""
465
+ lines << auto_compaction_status_line
466
+ if @active_session
467
+ lines << "Session: #{@active_session.name || @active_session.id}"
468
+ lines << "File: #{@active_session.path}"
469
+ end
470
+ lines.compact!
471
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{lines.join("\n")}\n")
472
+ end
473
+
474
+ def auto_compaction_status_line
475
+ settings = Kward::Compaction::Settings.from_config
476
+ return "Auto-compaction: disabled" unless settings.enabled
477
+
478
+ context_window = composer_context_window
479
+ return "Auto-compaction: enabled, unknown context window" unless context_window.to_i.positive?
480
+
481
+ reserve_tokens = Kward::Compactor.auto_compaction_reserve_tokens(
482
+ context_window: context_window,
483
+ configured_reserve_tokens: settings.reserve_tokens
484
+ )
485
+ percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
486
+ "Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
487
+ rescue StandardError => e
488
+ warn "Auto-compaction status unavailable: #{e.message}"
489
+ nil
490
+ end
491
+
492
+ def print_stats(argument)
493
+ result = TelemetryStats.new.collect(argument)
494
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{TelemetryStats.format(result)}\n")
495
+ rescue ArgumentError => e
496
+ message = e.message == TelemetryStats::USAGE ? e.message : "#{e.message}\n#{TelemetryStats::USAGE}"
497
+ @prompt.say("\n#{message}\n")
498
+ end
499
+
500
+ def handle_memory_command(argument, agent)
501
+ subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
502
+ manager = Memory::Manager.new
503
+ case subcommand
504
+ when "enable"
505
+ manager.enable
506
+ agent.conversation.refresh_system_message!
507
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
508
+ when "disable"
509
+ manager.disable
510
+ agent.conversation.memory_context = nil
511
+ agent.conversation.refresh_system_message!
512
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
513
+ when "auto-summary"
514
+ case rest.to_s.strip
515
+ when "enable", "on"
516
+ manager.auto_summary_enable
517
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
518
+ when "disable", "off"
519
+ manager.auto_summary_disable
520
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
521
+ else
522
+ @prompt.say("\nUsage: /memory auto-summary enable|disable\n")
523
+ end
524
+ when "core"
525
+ record = manager.add_core(unquote_argument(rest))
526
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
527
+ when "add"
528
+ record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
529
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
530
+ when "list"
531
+ @prompt.say("\n#{format_memory_list(manager.list)}\n")
532
+ when "forget"
533
+ forgotten = manager.forget_memory(rest.to_s.strip)
534
+ @prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
535
+ when "promote"
536
+ record = manager.promote_soft_to_core(rest.to_s.strip)
537
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted to core memory #{record["id"]}.\n")
538
+ when "inspect"
539
+ @prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
540
+ when "why"
541
+ explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
542
+ @prompt.say("\n#{format_memory_why(explanation)}\n")
543
+ when "summarize", "learn"
544
+ records = summarize_memory(agent.conversation, manager: manager)
545
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
546
+ else
547
+ @prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|inspect|why|summarize\n")
548
+ end
549
+ rescue StandardError => e
550
+ @prompt.say("\nMemory command failed: #{e.message}\n")
551
+ end
552
+
553
+ def summarize_memory(conversation, manager: Memory::Manager.new)
554
+ records = manager.summarize_conversation(conversation, client: @client)
555
+ @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
556
+ records
557
+ end
558
+
559
+ def unquote_argument(text)
560
+ value = text.to_s.strip
561
+ value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
562
+ value
563
+ end
564
+
565
+ def format_memory_list(memories)
566
+ lines = ["Core Memories:"]
567
+ Array(memories["core"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
568
+ lines << "- none" if Array(memories["core"]).empty?
569
+ lines << "Soft Memories:"
570
+ Array(memories["soft"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
571
+ lines << "- none" if Array(memories["soft"]).empty?
572
+ lines.join("\n")
573
+ end
574
+
575
+ def format_memory_why(explanation)
576
+ reasons = Array(explanation["reasons"])
577
+ return explanation["message"] || "No memories were retrieved." if reasons.empty?
578
+
579
+ (["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
580
+ end
581
+
582
+ def run_busy_local_command(activity: "loading")
583
+ return yield unless prompt_interface?
584
+
585
+ queued_inputs = []
586
+ result = nil
587
+ error = nil
588
+ @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
589
+
590
+ worker = Thread.new do
591
+ result = yield
592
+ rescue StandardError => e
593
+ error = e
594
+ end
595
+
596
+ while worker.alive?
597
+ collect_queued_input(queued_inputs)
598
+ sleep 0.02
599
+ end
600
+ worker.join
601
+ drain_queued_input(queued_inputs)
602
+ raise error if error
603
+
604
+ [result, queued_inputs]
605
+ ensure
606
+ @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
607
+ end
608
+
609
+ def run_busy_local_command_and_requeue(activity: "loading")
610
+ return yield unless prompt_interface?
611
+
612
+ result, queued_inputs = run_busy_local_command(activity: activity) { yield }
613
+ queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
614
+ result
615
+ end
616
+
617
+ def current_workspace_root
618
+ return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
619
+
620
+ Dir.pwd
621
+ end
622
+
623
+ def configure_settings
624
+ unless settings_overlay_available?
625
+ @prompt.say("\nSettings overlay is unavailable in this prompt.\n")
626
+ return
627
+ end
628
+
629
+ settings = ConfigFiles.overlay_settings
630
+ alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
631
+ return unless alignment
632
+
633
+ settings = ConfigFiles.update_overlay_settings("alignment" => alignment)
634
+ @prompt.update_overlay_settings(settings)
635
+
636
+ width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
637
+ return unless width
638
+
639
+ settings = ConfigFiles.update_overlay_settings("width" => width)
640
+ @prompt.update_overlay_settings(settings)
641
+ @prompt.say("\nSaved overlay settings.\n")
642
+ rescue StandardError => e
643
+ @prompt.say("\nSettings error: #{e.message}\n")
644
+ end
645
+
646
+ def login_interactively
647
+ unless login_picker_available?
648
+ @prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
649
+ return
650
+ end
651
+
652
+ selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
653
+ provider = selected_login_provider(selected)
654
+ return unless provider
655
+
656
+ login(provider: provider)
657
+ reload_client_config
658
+ rescue StandardError => e
659
+ @prompt.say("\nLogin error: #{e.message}\n")
660
+ end
661
+
662
+ def configure_model(conversation = nil, models: nil)
663
+ unless model_overlay_available?
664
+ @prompt.say("\nModel overlay is unavailable in this prompt.\n")
665
+ return
666
+ end
667
+
668
+ models ||= normalized_available_models
669
+ choices = model_choices(models)
670
+ selected = @prompt.select("Default model", choices, title: "Models", custom: true)
671
+ return unless selected
672
+
673
+ provider, model = selected_model(selected, models)
674
+ raise "Model must be a non-empty string" if model.to_s.strip.empty?
675
+
676
+ ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
677
+ reload_client_config
678
+ refresh_conversation_runtime(conversation)
679
+ @prompt.redraw if @prompt.respond_to?(:redraw)
680
+ rescue StandardError => e
681
+ @prompt.say("\nModel error: #{e.message}\n")
682
+ end
683
+
684
+ def print_openrouter_catalog
685
+ unless @client.respond_to?(:openrouter_catalog)
686
+ @prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
687
+ return
688
+ end
689
+
690
+ models = Array(@client.openrouter_catalog)
691
+ if models.empty?
692
+ @prompt.say("\nNo OpenRouter catalog models available.\n")
693
+ else
694
+ ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
695
+ @prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
696
+ end
697
+ rescue StandardError => e
698
+ @prompt.say("\nOpenRouter catalog error: #{e.message}\n")
699
+ end
700
+
701
+ def configure_reasoning(conversation = nil)
702
+ unless model_overlay_available?
703
+ @prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
704
+ return
705
+ end
706
+
707
+ choices = ModelInfo::REASONING_EFFORT_CHOICES
708
+ selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
709
+ return unless selected
710
+
711
+ effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
712
+ raise "Reasoning effort must be low, medium, high, or extra high" unless effort
713
+
714
+ ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
715
+ reload_client_config
716
+ refresh_conversation_runtime(conversation)
717
+ @prompt.redraw if @prompt.respond_to?(:redraw)
718
+ rescue StandardError => e
719
+ @prompt.say("\nReasoning error: #{e.message}\n")
720
+ end
721
+
722
+ def login_picker_available?
723
+ @prompt.respond_to?(:select)
724
+ end
725
+
726
+ def login_provider_choices
727
+ ["OpenAI", "OpenRouter", "GitHub"]
728
+ end
729
+
730
+ def selected_login_provider(selected)
731
+ case selected.to_s.downcase
732
+ when /\Aopenai\b/
733
+ "openai"
734
+ when /\Aopenrouter\b/
735
+ "openrouter"
736
+ when /\Agithub\b/
737
+ "github"
738
+ end
739
+ end
740
+
741
+ def model_overlay_available?
742
+ @prompt.respond_to?(:select)
743
+ end
744
+
745
+ def settings_overlay_available?
746
+ @prompt.respond_to?(:select) && @prompt.respond_to?(:update_overlay_settings)
747
+ end
748
+
749
+ def choose_overlay_setting(message, choices, values)
750
+ choice = @prompt.select(message, choices, title: "Settings")
751
+ return nil unless choice
752
+
753
+ values.find { |value| choice.to_s.downcase.start_with?(value) }
754
+ end
755
+
756
+ def normalized_available_models
757
+ current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
758
+ current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
759
+ current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
760
+ models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
761
+ models.map do |model|
762
+ ModelInfo.normalize(
763
+ model,
764
+ current_provider: current_provider,
765
+ current_model: current_model,
766
+ current_reasoning_effort: current_reasoning
767
+ )
768
+ end
769
+ end
770
+
771
+ def model_choices(models)
772
+ choices = models.map do |model|
773
+ label = "#{model[:provider]} #{model[:id]}"
774
+ label += " (current)" if model[:current]
775
+ label
776
+ end
777
+ choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
778
+ end
779
+
780
+ def selected_model(selected, models)
781
+ text = selected.to_s.sub(/ \(current\)\z/, "").strip
782
+ known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
783
+ return [known[:provider], known[:id]] if known
784
+
785
+ provider, model = text.split(/\s+/, 2)
786
+ if ["Codex", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
787
+ [provider, model.strip]
788
+ else
789
+ [current_model_provider, text]
790
+ end
791
+ end
792
+
793
+ def reasoning_choices(choices)
794
+ current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
795
+ choices.map do |effort, label|
796
+ text = label.dup
797
+ text += " (current)" if current == effort
798
+ text
799
+ end
800
+ end
801
+
802
+ def current_model_provider
803
+ @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
804
+ end
805
+
806
+ def current_model_id
807
+ @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
808
+ end
809
+
810
+ def current_reasoning_effort
811
+ @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
812
+ end
813
+
814
+ def reload_client_config
815
+ @client.reload_config if @client.respond_to?(:reload_config)
816
+ end
817
+
818
+ def refresh_conversation_runtime(conversation)
819
+ return unless conversation&.respond_to?(:update_runtime_context!)
820
+
821
+ conversation.update_runtime_context!(model: current_model_id, reasoning_effort: current_reasoning_effort)
822
+ @active_session.update_runtime(model: conversation.model, reasoning_effort: conversation.reasoning_effort) if @active_session&.respond_to?(:update_runtime)
823
+ update_assistant_prompt(conversation)
824
+ end
825
+
826
+ def overlay_alignment_choices(settings)
827
+ ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
828
+ label = alignment.capitalize
829
+ label += " (current)" if settings["alignment"] == alignment
830
+ label
831
+ end
832
+ end
833
+
834
+ def overlay_width_choices(settings)
835
+ ConfigFiles::OVERLAY_WIDTHS.map do |width|
836
+ label = width.capitalize
837
+ label += " (current)" if settings["width"] == width
838
+ label
839
+ end
840
+ end
841
+
842
+ def start_new_session(session_store)
843
+ return say_sessions_unavailable unless session_store
844
+
845
+ previous_session = @active_session
846
+ @active_session = track_session(session_store.create)
847
+ reset_session_diff
848
+ cleanup_replaced_session(previous_session)
849
+ conversation = new_conversation(workspace_root: session_store.cwd)
850
+ @active_session.attach(conversation)
851
+ update_assistant_prompt(conversation)
852
+ clear_prompt_transcript
853
+ print_visual_banner
854
+ build_interactive_agent(conversation)
855
+ end
856
+
857
+ def resume_session(session_store, argument)
858
+ return say_sessions_unavailable unless session_store
859
+
860
+ path = argument.to_s.strip
861
+ path = select_session_path(session_store) if path.empty?
862
+ return nil if path.to_s.empty?
863
+
864
+ previous_session = @active_session
865
+ @active_session, conversation = session_store.load(path, workspace: Workspace.new(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
866
+ reset_session_diff(@active_session.path)
867
+ track_session(@active_session)
868
+ cleanup_replaced_session(previous_session)
869
+ update_assistant_prompt(conversation)
870
+ restore_prompt_transcript do
871
+ @prompt.say("\nResumed session: #{@active_session.path}\n")
872
+ render_conversation_transcript(conversation)
873
+ end
874
+ agent = build_interactive_agent(conversation)
875
+ @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
876
+ agent
877
+ rescue StandardError => e
878
+ @prompt.say("\nError: #{e.message}\n")
879
+ nil
880
+ end
881
+
882
+ def rename_session(argument)
883
+ unless @active_session
884
+ @prompt.say("\nNo active persisted session.\n")
885
+ return
886
+ end
887
+
888
+ @active_session.rename(argument)
889
+ label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
890
+ @prompt.say("\n#{label}\n")
891
+ end
892
+
893
+ def clone_session(session_store, agent)
894
+ return say_sessions_unavailable unless session_store
895
+
896
+ previous_session = @active_session
897
+ @active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
898
+ reset_session_diff(@active_session.path)
899
+ cleanup_replaced_session(previous_session)
900
+ @prompt.say("\nCloned session: #{@active_session.path}\n")
901
+ render_conversation_transcript(agent.conversation)
902
+ agent
903
+ end
904
+
905
+ def copy_session_text(conversation, argument)
906
+ target = copy_target(argument)
907
+ unless target
908
+ @prompt.say("\nUsage: /copy [last|transcript]\n")
909
+ return
910
+ end
911
+
912
+ content = copy_target_content(conversation, target)
913
+ if content.to_s.empty?
914
+ @prompt.say("\nNothing to copy.\n")
915
+ return
916
+ end
917
+
918
+ result = Clipboard.new(output: $stdout).copy(content)
919
+ if result.success?
920
+ @prompt.say("\nCopied #{copy_target_label(target)}.\n")
921
+ else
922
+ @prompt.say("\nCopy failed: #{result.message}.\n")
923
+ end
924
+ end
925
+
926
+ def copy_target(argument)
927
+ target = argument.to_s.strip.downcase
928
+ target = "last" if target.empty?
929
+ return target if ["last", "transcript"].include?(target)
930
+
931
+ nil
932
+ end
933
+
934
+ def copy_target_content(conversation, target)
935
+ case target
936
+ when "last"
937
+ last_assistant_copy_text(conversation)
938
+ when "transcript"
939
+ markdown_transcript(conversation)
940
+ else
941
+ ""
942
+ end
943
+ end
944
+
945
+ def last_assistant_copy_text(conversation)
946
+ message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
947
+ return "" unless message
948
+
949
+ message_content_text(message_content(message))
950
+ end
951
+
952
+ def copy_target_label(target)
953
+ target == "transcript" ? "transcript" : "last assistant response"
954
+ end
955
+
956
+ def compact_context(agent, argument)
957
+ result = Compactor.new(
958
+ conversation: agent.conversation,
959
+ client: @client,
960
+ tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
961
+ ).compact(custom_instructions: argument)
962
+ @prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
963
+ render_transcript_block("Assistant", result.summary)
964
+ rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
965
+ @prompt.say("\n#{e.message}\n")
966
+ rescue StandardError => e
967
+ @prompt.say("\nCompaction error: #{e.message}\n")
968
+ end
969
+
970
+
971
+ def render_conversation_transcript(conversation)
972
+ tool_calls_by_id = {}
973
+ @prompt.say("\n#{colored("Transcript", :cyan, :bold)}\n")
974
+ conversation.messages.each do |message|
975
+ role = message_role(message)
976
+ next if role == "system"
977
+
978
+ case role
979
+ when "user"
980
+ print_user_transcript(
981
+ message_user_transcript_input(message),
982
+ display_input: message_user_display_text(message),
983
+ attachment_references: message_image_references(message),
984
+ image_parts: message_image_parts(message)
985
+ )
986
+ when "assistant"
987
+ render_reasoning(message)
988
+ render_assistant_message(message)
989
+ message_tool_calls(message).each do |tool_call|
990
+ tool_calls_by_id[tool_call_id(tool_call)] = tool_call
991
+ render_tool_call(tool_call)
992
+ end
993
+ when "tool"
994
+ render_tool_message(message, tool_calls_by_id)
995
+ when "compactionSummary"
996
+ render_transcript_block("Compaction summary", message_summary(message))
997
+ else
998
+ render_transcript_block(role.to_s.capitalize, message_content_text(message_content(message)))
999
+ end
1000
+ end
1001
+ end
1002
+
1003
+ def render_reasoning(message)
1004
+ reasoning = message_reasoning(message)
1005
+ render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
1006
+ end
1007
+
1008
+ def render_assistant_message(message)
1009
+ content = message_content_text(message_content(message))
1010
+ return if content.empty?
1011
+
1012
+ render_transcript_block("Assistant", content)
1013
+ end
1014
+
1015
+ def render_tool_message(message, tool_calls_by_id)
1016
+ tool_call = tool_calls_by_id[message_tool_call_id(message)] || synthetic_tool_call(message_name(message), message_tool_call_id(message))
1017
+ render_tool_result(tool_call, message_content(message).to_s)
1018
+ end
1019
+
1020
+ def render_tool_call(tool_call)
1021
+ if prompt_interface?
1022
+ print_tool_call(tool_call)
1023
+ else
1024
+ @prompt.say("\n#{colored("Tool>", :magenta, :bold)}\n#{tool_command(tool_call)}\n")
1025
+ end
1026
+ end
1027
+
1028
+ def render_tool_result(tool_call, content)
1029
+ summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
1030
+ if prompt_interface?
1031
+ print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
1032
+ else
1033
+ @prompt.say("\n#{colored("Tool output>", :cyan, :bold)}\n#{summary}\n")
1034
+ end
1035
+ end
1036
+
1037
+ def render_transcript_block(label, content)
1038
+ return if content.to_s.empty?
1039
+
1040
+ rendered = render_markdown_transcript(content)
1041
+ if prompt_interface?
1042
+ print_block_delta(label, rendered)
1043
+ finish_stream_block
1044
+ else
1045
+ @prompt.say("\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n#{rendered}\n")
1046
+ end
1047
+ end
1048
+
1049
+ def render_markdown_transcript(content)
1050
+ ANSI.markdown(content, enabled: @color_enabled)
1051
+ end
1052
+
1053
+ def append_markdown_delta(chunks, label, delta)
1054
+ text = delta.to_s
1055
+ return if text.empty?
1056
+
1057
+ if chunks.last&.first == label
1058
+ chunks.last[1] << text
1059
+ else
1060
+ chunks << [label, +text]
1061
+ end
1062
+ end
1063
+
1064
+ def flush_markdown_deltas(chunks, finish: true, streams: nil)
1065
+ wrote = false
1066
+ entries = ordered_markdown_entries(chunks.dup)
1067
+ if finish && streams
1068
+ streamed_labels = entries.map(&:first)
1069
+ entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
1070
+ end
1071
+
1072
+ entries.each do |label, content|
1073
+ next if content.empty? && !(finish && streams&.key?(label))
1074
+
1075
+ rendered = if streams
1076
+ streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
1077
+ streams[label].render(content, final: finish)
1078
+ else
1079
+ render_markdown_transcript(content)
1080
+ end
1081
+ streams.delete(label) if finish && streams
1082
+ next if rendered.empty?
1083
+
1084
+ print_block_delta(label, rendered)
1085
+ finish_stream_block if finish
1086
+ wrote = true
1087
+ end
1088
+ chunks.clear
1089
+ wrote
1090
+ end
1091
+
1092
+ def ordered_markdown_entries(entries)
1093
+ labels = entries.map(&:first)
1094
+ return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
1095
+
1096
+ grouped = { "Reasoning" => +"", "Assistant" => +"" }
1097
+ others = []
1098
+ entries.each do |label, content|
1099
+ if grouped.key?(label)
1100
+ grouped[label] << content.to_s
1101
+ else
1102
+ others << [label, content]
1103
+ end
1104
+ end
1105
+
1106
+ [["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
1107
+ end
1108
+
1109
+ def message_reasoning(message)
1110
+ direct = message["reasoning_summary"] || message[:reasoning_summary]
1111
+ return direct.to_s unless direct.to_s.empty?
1112
+
1113
+ content = message_content(message)
1114
+ return "" unless content.is_a?(Array)
1115
+
1116
+ content.filter_map do |part|
1117
+ type = part["type"] || part[:type]
1118
+ next unless ["thinking", "reasoning"].include?(type)
1119
+
1120
+ part["thinking"] || part[:thinking] || part["text"] || part[:text]
1121
+ end.join("\n")
1122
+ end
1123
+
1124
+ def message_content_text(content)
1125
+ case content
1126
+ when Array
1127
+ content.filter_map do |part|
1128
+ type = part["type"] || part[:type]
1129
+ if type == "text"
1130
+ part["text"] || part[:text]
1131
+ elsif type == "image"
1132
+ path = part["path"] || part[:path]
1133
+ media_type = part["media_type"] || part[:media_type] || "image"
1134
+ "[#{media_type}#{path ? ": #{path}" : ""}]"
1135
+ end
1136
+ end.join("\n")
1137
+ else
1138
+ content.to_s
1139
+ end
1140
+ end
1141
+
1142
+ def message_display_text(message)
1143
+ display_content = MessageAccess.display_content(message)
1144
+ return display_content.to_s unless display_content.nil?
1145
+
1146
+ message_content_text(message_content(message))
1147
+ end
1148
+
1149
+ def message_user_display_text(message)
1150
+ display_content = MessageAccess.display_content(message)
1151
+ return display_content.to_s unless display_content.nil?
1152
+
1153
+ content = message_content(message)
1154
+ return content.to_s unless content.is_a?(Array)
1155
+
1156
+ text = content.filter_map do |part|
1157
+ type = part["type"] || part[:type]
1158
+ next unless type == "text"
1159
+
1160
+ part["text"] || part[:text]
1161
+ end.join("\n")
1162
+ Kward::ImageAttachments.display_text_without_references(text, Kward::ImageAttachments.references_from_text(text).select { |reference| reference[:status] == :attached })
1163
+ end
1164
+
1165
+ def message_user_transcript_input(message)
1166
+ content = message_content(message)
1167
+ return content.to_s unless content.is_a?(Array)
1168
+
1169
+ message_user_display_text(message)
1170
+ end
1171
+
1172
+ def message_image_parts(message)
1173
+ content = message_content(message)
1174
+ return [] unless content.is_a?(Array)
1175
+
1176
+ content.select do |part|
1177
+ type = part["type"] || part[:type]
1178
+ type == "image"
1179
+ end
1180
+ end
1181
+
1182
+ def message_image_references(message)
1183
+ message_image_parts(message).map { |part| image_part_reference(part) }
1184
+ end
1185
+
1186
+ def image_part_reference(part)
1187
+ data = part[:data] || part["data"]
1188
+ path = part[:path] || part["path"]
1189
+ media_type = part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"] || "image"
1190
+ {
1191
+ status: :attached,
1192
+ type: "image",
1193
+ label: path.to_s.empty? ? "pasted image" : File.basename(path),
1194
+ media_type: media_type,
1195
+ size_bytes: decoded_image_size(data),
1196
+ path: path
1197
+ }
1198
+ end
1199
+
1200
+ def decoded_image_size(data)
1201
+ return nil if data.to_s.empty?
1202
+
1203
+ Base64.decode64(data.to_s.gsub(/\s+/, "")).bytesize
1204
+ rescue ArgumentError
1205
+ nil
1206
+ end
1207
+
1208
+ def synthetic_tool_call(name, id)
1209
+ {
1210
+ "id" => id || "restored_tool",
1211
+ "type" => "function",
1212
+ "function" => { "name" => name || "tool", "arguments" => "{}" }
1213
+ }
1214
+ end
1215
+
1216
+ def message_role(message)
1217
+ MessageAccess.role(message)
1218
+ end
1219
+
1220
+ def message_content(message)
1221
+ MessageAccess.content(message)
1222
+ end
1223
+
1224
+ def message_summary(message)
1225
+ MessageAccess.summary(message) || message_content(message)
1226
+ end
1227
+
1228
+ def message_name(message)
1229
+ MessageAccess.name(message)
1230
+ end
1231
+
1232
+ def message_tool_call_id(message)
1233
+ MessageAccess.tool_call_id(message)
1234
+ end
1235
+
1236
+ def message_tool_calls(message)
1237
+ MessageAccess.tool_calls(message)
1238
+ end
1239
+
1240
+ def tool_call_id(tool_call)
1241
+ tool_call["id"] || tool_call[:id]
1242
+ end
1243
+
1244
+ def export_session(conversation, argument)
1245
+ path = export_path(argument)
1246
+ File.write(path, markdown_transcript(conversation))
1247
+ @prompt.say("\nExported session: #{path}\n")
1248
+ rescue StandardError => e
1249
+ @prompt.say("\nError: #{e.message}\n")
1250
+ end
1251
+
1252
+ def say_sessions_unavailable
1253
+ @prompt.say("\nSessions are unavailable for this interactive loop.\n")
1254
+ nil
1255
+ end
1256
+
1257
+ def clear_prompt_transcript
1258
+ @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
1259
+ end
1260
+
1261
+ def restore_prompt_transcript(&block)
1262
+ if @prompt.respond_to?(:restore_transcript)
1263
+ @prompt.restore_transcript(&block)
1264
+ else
1265
+ block.call
1266
+ end
1267
+ end
1268
+
1269
+ def select_session_path(session_store)
1270
+ recent_limit = 20
1271
+ sessions = session_store.recent_tree(limit: recent_limit + 1)
1272
+ .reject { |session| active_empty_unnamed_session_info?(session) }
1273
+ .first(recent_limit)
1274
+ if sessions.empty?
1275
+ @prompt.say("\nNo saved sessions found.\n")
1276
+ return nil
1277
+ end
1278
+
1279
+ labels = sessions.map { |session| session_label(session) }
1280
+ if @prompt.respond_to?(:select)
1281
+ choice = @prompt.select("Session>", labels)
1282
+ return nil unless choice
1283
+
1284
+ selected = sessions[labels.index(choice)]
1285
+ return selected&.path
1286
+ end
1287
+
1288
+ numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
1289
+ @prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
1290
+ answer = @prompt.ask("Session number or path>").to_s.strip
1291
+ if answer.match?(/\A\d+\z/)
1292
+ sessions[answer.to_i - 1]&.path
1293
+ else
1294
+ answer
1295
+ end
1296
+ end
1297
+
1298
+ def active_empty_unnamed_session_info?(session)
1299
+ return false unless @active_session
1300
+ return false unless File.expand_path(session.path) == File.expand_path(@active_session.path)
1301
+
1302
+ session.name.to_s.strip.empty? && session.message_count.to_i.zero?
1303
+ end
1304
+
1305
+ def session_label(session)
1306
+ title = session.name.to_s.strip
1307
+ title = session.first_message.to_s.strip if title.empty?
1308
+ title = session.id if title.empty?
1309
+ "#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
1310
+ end
1311
+
1312
+ def session_tree_prefix(session)
1313
+ depth = session.respond_to?(:depth) ? session.depth.to_i : 0
1314
+ return "" if depth <= 0
1315
+
1316
+ ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
1317
+ prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
1318
+ branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
1319
+ prefix + branch
1320
+ end
1321
+
1322
+ def export_path(argument)
1323
+ default_path = if @active_session
1324
+ @active_session.path.sub(/\.jsonl\z/, ".md")
1325
+ else
1326
+ File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
1327
+ end
1328
+ session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
1329
+
1330
+ ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
1331
+ end
1332
+
1333
+ def markdown_transcript(conversation)
1334
+ TranscriptExport.content(conversation)
1335
+ end
1336
+
1337
+ def setup_interactive_prompt
1338
+ return unless @stdin.tty?
1339
+ return unless @prompt.is_a?(TTY::Prompt)
1340
+
1341
+ prompt_interface = load_prompt_interface
1342
+ return unless prompt_interface
1343
+
1344
+ @prompt = prompt_interface.new(
1345
+ slash_commands: slash_command_entries,
1346
+ overlay_settings: ConfigFiles.overlay_settings,
1347
+ footer: prompt_footer_renderer,
1348
+ composer_status: method(:composer_status_text),
1349
+ busy_help: ConfigFiles.composer_busy_help?,
1350
+ attachment_badges: method(:composer_attachment_badges),
1351
+ attachment_parser: method(:composer_attachment_parser),
1352
+ banner_pixels: Kward::PromptInterface::BANNER_LOGO_PIXELS,
1353
+ banner_message: Kward::PromptInterface::BANNER_MESSAGE
1354
+ )
1355
+ @prompt.start
1356
+ end
1357
+
1358
+ def load_prompt_interface
1359
+ require_relative "prompt_interface"
1360
+ PromptInterface
1361
+ rescue LoadError => e
1362
+ raise unless missing_tty_tui_load_error?(e)
1363
+
1364
+ nil
1365
+ end
1366
+
1367
+ def missing_tty_tui_load_error?(error)
1368
+ ["tty-cursor", "tty-reader", "tty-screen"].include?(error.path) ||
1369
+ error.message.match?(/cannot load such file -- tty-(cursor|reader|screen)/)
1370
+ end
1371
+
1372
+ def prompt_interface?
1373
+ @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
1374
+ end
1375
+
1376
+ def print_visual_banner
1377
+ @prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
1378
+ end
1379
+
1380
+ def prompt_templates
1381
+ @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
1382
+ end
1383
+
1384
+ def plugin_registry
1385
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
1386
+ end
1387
+
1388
+ def plugin_commands
1389
+ plugin_registry.commands
1390
+ end
1391
+
1392
+ def plugin_command_for(command)
1393
+ plugin_registry.command_for(command)
1394
+ end
1395
+
1396
+ def reserved_slash_command_names
1397
+ BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
1398
+ end
1399
+
1400
+ def slash_command_entries
1401
+ prompt_entries = prompt_templates.map do |template|
1402
+ {
1403
+ name: template.command,
1404
+ description: template.description,
1405
+ argument_hint: template.argument_hint
1406
+ }
1407
+ end
1408
+ plugin_entries = plugin_commands.map(&:entry)
1409
+ BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
1410
+ end
1411
+
1412
+ def prompt_template_for(command)
1413
+ prompt_templates.find { |template| template.command == command }
1414
+ end
1415
+
1416
+ def expand_prompt_template(input)
1417
+ PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
1418
+ end
1419
+
1420
+ def run_plugin_command(name, argument, agent)
1421
+ command = plugin_command_for(name)
1422
+ return [false, nil] unless command
1423
+
1424
+ agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
1425
+ context = plugin_context(agent.conversation, argument)
1426
+ command.handler.call(argument, context)
1427
+ [true, nil]
1428
+ rescue StandardError => e
1429
+ @prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
1430
+ [true, nil]
1431
+ end
1432
+
1433
+ def prompt_footer_renderer
1434
+ renderer = plugin_registry.footer_renderer
1435
+ return nil unless renderer
1436
+
1437
+ lambda do
1438
+ context = plugin_context(current_footer_conversation, "")
1439
+ renderer.call(context).to_s
1440
+ rescue StandardError => e
1441
+ warn "Warning: Kward plugin footer error: #{e.message}"
1442
+ ""
1443
+ end
1444
+ end
1445
+
1446
+ def composer_status_text
1447
+ provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
1448
+ model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
1449
+ reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
1450
+ reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
1451
+ text = "#{provider} #{model} · #{reasoning}"
1452
+ parts = []
1453
+ diff = composer_session_diff_text
1454
+ parts << diff if diff
1455
+ usage = composer_context_usage(provider, model)
1456
+ parts << composer_context_percent_text(usage[:percent]) if usage
1457
+ parts << text
1458
+ parts.join(" · ")
1459
+ end
1460
+
1461
+ def composer_session_diff_text
1462
+ return nil if @session_diff.nil? || @session_diff.empty?
1463
+
1464
+ additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
1465
+ deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
1466
+ "#{additions}|#{deletions}"
1467
+ end
1468
+
1469
+ def composer_context_percent_text(percent)
1470
+ value = percent.round
1471
+ color = if value >= 85
1472
+ :red
1473
+ elsif value >= 50
1474
+ :yellow
1475
+ end
1476
+ ANSI.colorize("#{value}%", color, enabled: @color_enabled)
1477
+ end
1478
+
1479
+ def composer_context_window
1480
+ provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
1481
+ model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
1482
+ provider = ModelInfo.provider_label(provider)
1483
+ @client.respond_to?(:current_context_window) ? @client.current_context_window : ModelInfo.context_window(provider, model)
1484
+ end
1485
+
1486
+ def composer_context_usage(provider, model)
1487
+ context_window = composer_context_window
1488
+ context_parts = if @client.respond_to?(:current_context_parts)
1489
+ @client.current_context_parts(current_footer_conversation.messages, footer_tool_schemas)
1490
+ else
1491
+ { provider: provider, model: model, messages: current_footer_conversation.messages, tools: footer_tool_schemas }
1492
+ end
1493
+ @context_usage.call(
1494
+ provider: provider,
1495
+ model: model,
1496
+ context_window: context_window,
1497
+ context_parts: context_parts
1498
+ )
1499
+ end
1500
+
1501
+ def footer_tool_schemas
1502
+ @footer_tool_registry&.schemas || []
1503
+ end
1504
+
1505
+ def current_footer_conversation
1506
+ @footer_conversation || Conversation.new(system_message: nil)
1507
+ end
1508
+
1509
+ def plugin_context(conversation, args)
1510
+ PluginRegistry::Context.new(
1511
+ conversation: conversation,
1512
+ args: args,
1513
+ session: @active_session,
1514
+ workspace_root: conversation.workspace_root,
1515
+ say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
1516
+ )
1517
+ end
1518
+
1519
+ def selected_slash_command_input(input)
1520
+ return nil if prompt_interface?
1521
+ return nil unless @prompt.respond_to?(:select)
1522
+ return nil unless input.match?(%r{\A/[^\s/]*\z})
1523
+ return nil if prompt_template_for(input.delete_prefix("/"))
1524
+
1525
+ prefix = input.delete_prefix("/").downcase
1526
+ return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
1527
+
1528
+ matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
1529
+ return nil if matches.empty?
1530
+
1531
+ labels = matches.map { |entry| slash_command_label(entry) }
1532
+ choice = @prompt.select("Slash command>", labels)
1533
+ entry = matches[labels.index(choice)]
1534
+ entry ? "/#{entry[:name]}" : nil
1535
+ end
1536
+
1537
+ def slash_command_label(entry)
1538
+ hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
1539
+ description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
1540
+ "/#{entry[:name]}#{hint}#{description}"
1541
+ end
1542
+
1543
+ def run_interactive_turn(agent, input, display_input: nil)
1544
+ prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
1545
+ print_user_transcript(input, display_input: display_input) if prompt_interface?
1546
+ return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
1547
+
1548
+ queued_inputs = []
1549
+ cancellation = Cancellation.new
1550
+ cancelled = false
1551
+ steering = steering_supported? ? Steering.new : nil
1552
+ event_queue = Queue.new
1553
+ stream_state = {
1554
+ streamed: false,
1555
+ last_flush: monotonic_now,
1556
+ stream_block_open: false,
1557
+ markdown_streams: {},
1558
+ defer_assistant_streaming: defer_assistant_streaming?(agent)
1559
+ }
1560
+ markdown_chunks = []
1561
+ answer = nil
1562
+ error = nil
1563
+ @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
1564
+
1565
+ worker = Thread.new do
1566
+ options = agent_display_options(display_input)
1567
+ options[:cancellation] = cancellation
1568
+ options[:steering] = steering if steering
1569
+ answer = agent.ask(input, **options) do |event|
1570
+ event_queue << event
1571
+ end
1572
+ rescue StandardError => e
1573
+ error = e
1574
+ end
1575
+ worker.report_on_exception = false
1576
+
1577
+ while worker.alive?
1578
+ begin
1579
+ poll_result = collect_busy_input(queued_inputs, steering)
1580
+ sleep 0.01
1581
+ rescue Interrupt
1582
+ poll_result = PromptInterface::CANCEL_INPUT
1583
+ end
1584
+ if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
1585
+ cancelled = true
1586
+ cancellation.cancel!
1587
+ worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
1588
+ end
1589
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
1590
+ end
1591
+ begin
1592
+ worker.join
1593
+ rescue Cancellation::CancelledError => e
1594
+ error ||= e
1595
+ end
1596
+ drain_busy_input(queued_inputs, nil) unless cancelled
1597
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
1598
+ raise error if error && !error.is_a?(Cancellation::CancelledError)
1599
+
1600
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
1601
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
1602
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
1603
+ queued_inputs
1604
+ ensure
1605
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
1606
+ end
1607
+
1608
+ def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
1609
+ drained = 0
1610
+ loop do
1611
+ break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
1612
+
1613
+ event = event_queue.pop(true)
1614
+ drained += 1
1615
+ notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
1616
+ handle_interactive_event(event, markdown_chunks, stream_state)
1617
+ rescue ThreadError
1618
+ break
1619
+ end
1620
+
1621
+ flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
1622
+ end
1623
+
1624
+ def notify_plugin_transcript_event(event, conversation)
1625
+ return unless conversation
1626
+ return if plugin_registry.transcript_event_handlers.empty?
1627
+
1628
+ plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
1629
+ end
1630
+
1631
+ def handle_interactive_event(event, markdown_chunks, stream_state)
1632
+ case event
1633
+ when Events::ReasoningDelta
1634
+ stream_state[:streamed] = true
1635
+ append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
1636
+ when Events::AssistantDelta
1637
+ stream_state[:streamed] = true
1638
+ append_markdown_delta(markdown_chunks, "Assistant", event.delta)
1639
+ when Events::SteeringApplied
1640
+ @prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
1641
+ when Events::Retry
1642
+ stream_state[:streamed] = true
1643
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1644
+ print_retry(event)
1645
+ when Events::ToolCall
1646
+ stream_state[:streamed] = true
1647
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1648
+ print_tool_call(event.tool_call)
1649
+ when Events::ToolResult
1650
+ stream_state[:streamed] = true
1651
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1652
+ update_session_diff(event.content)
1653
+ print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
1654
+ end
1655
+ end
1656
+
1657
+ def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
1658
+ if force
1659
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1660
+ return
1661
+ end
1662
+ return if markdown_chunks.empty?
1663
+ return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
1664
+
1665
+ chunks_to_flush = markdown_chunks
1666
+ if stream_state[:defer_assistant_streaming]
1667
+ chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
1668
+ return if chunks_to_flush.empty?
1669
+
1670
+ markdown_chunks.replace(delayed_chunks)
1671
+ end
1672
+
1673
+ stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
1674
+ stream_state[:last_flush] = monotonic_now
1675
+ end
1676
+
1677
+ def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1678
+ wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
1679
+ finish_stream_block if stream_state[:stream_block_open] && !wrote
1680
+ stream_state[:stream_block_open] = false
1681
+ stream_state[:last_flush] = monotonic_now
1682
+ end
1683
+
1684
+ def split_deferred_assistant_entries(markdown_chunks)
1685
+ markdown_chunks.partition { |label, _content| label != "Assistant" }
1686
+ end
1687
+
1688
+ def monotonic_now
1689
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1690
+ end
1691
+
1692
+ def collect_queued_input(queued_inputs)
1693
+ collect_busy_input(queued_inputs, nil)
1694
+ end
1695
+
1696
+ def collect_busy_input(queued_inputs, steering)
1697
+ return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
1698
+
1699
+ poll_result = @prompt.poll_input
1700
+ case poll_result
1701
+ when String
1702
+ if steering && !poll_result.strip.empty?
1703
+ begin
1704
+ steering.submit(poll_result)
1705
+ @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
1706
+ rescue StandardError
1707
+ queued_inputs << poll_result
1708
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
1709
+ end
1710
+ else
1711
+ queued_inputs << poll_result unless poll_result.strip.empty?
1712
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
1713
+ end
1714
+ when PromptInterface::EXIT_INPUT
1715
+ queued_inputs << "/exit"
1716
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
1717
+ end
1718
+ poll_result
1719
+ end
1720
+
1721
+ def drain_queued_input(queued_inputs)
1722
+ drain_busy_input(queued_inputs, nil)
1723
+ end
1724
+
1725
+ def drain_busy_input(queued_inputs, steering)
1726
+ deadline = Time.now + 0.15
1727
+ loop do
1728
+ poll_result = collect_busy_input(queued_inputs, steering)
1729
+ break if Time.now > deadline && poll_result.nil?
1730
+
1731
+ sleep 0.01
1732
+ end
1733
+ end
1734
+
1735
+ def steering_supported?
1736
+ @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
1737
+ end
1738
+
1739
+ def defer_assistant_streaming?(agent)
1740
+ return false unless agent.respond_to?(:conversation)
1741
+
1742
+ conversation = agent.conversation
1743
+ model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
1744
+ ModelInfo.reasoning_supported?(current_model_provider, model)
1745
+ end
1746
+
1747
+ def run_blocking_interactive_turn(agent, input, display_input: nil)
1748
+ streamed = false
1749
+ markdown_chunks = []
1750
+ answer = agent.ask(input, **agent_display_options(display_input)) do |event|
1751
+ case event
1752
+ when Events::ReasoningDelta
1753
+ streamed = true
1754
+ append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
1755
+ when Events::AssistantDelta
1756
+ streamed = true
1757
+ append_markdown_delta(markdown_chunks, "Assistant", event.delta)
1758
+ when Events::Retry
1759
+ streamed = true
1760
+ flush_markdown_deltas(markdown_chunks)
1761
+ print_retry(event)
1762
+ when Events::ToolCall
1763
+ streamed = true
1764
+ flush_markdown_deltas(markdown_chunks)
1765
+ print_tool_call(event.tool_call)
1766
+ when Events::ToolResult
1767
+ streamed = true
1768
+ flush_markdown_deltas(markdown_chunks)
1769
+ print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
1770
+ end
1771
+ end
1772
+ flush_markdown_deltas(markdown_chunks) if streamed
1773
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
1774
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
1775
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
1776
+ []
1777
+ end
1778
+
1779
+ def prepare_memory_context(conversation, input)
1780
+ manager = Memory::Manager.new
1781
+ retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
1782
+ conversation.last_memory_retrieval = retrieval
1783
+ conversation.memory_context = manager.memory_block(retrieval)
1784
+ conversation.refresh_system_message!
1785
+ rescue StandardError => e
1786
+ warn "Memory retrieval failed: #{e.message}"
1787
+ nil
1788
+ end
1789
+
1790
+ def persist_memory_state(conversation)
1791
+ @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
1792
+ rescue StandardError
1793
+ nil
1794
+ end
1795
+
1796
+ def auto_summarize_memory(conversation)
1797
+ manager = Memory::Manager.new
1798
+ return unless manager.enabled? && manager.auto_summary_enabled?
1799
+
1800
+ summarize_memory(conversation, manager: manager)
1801
+ rescue StandardError => e
1802
+ warn "Memory auto-summary failed: #{e.message}"
1803
+ nil
1804
+ end
1805
+
1806
+ def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
1807
+ visible_input = display_input.nil? ? input : display_input
1808
+ @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
1809
+ print_attachment_badges(input, references: attachment_references)
1810
+ print_pasted_images(input, image_parts: image_parts)
1811
+ end
1812
+
1813
+ def print_attachment_badges(input, references: nil)
1814
+ badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
1815
+ return if badges.empty?
1816
+
1817
+ @prompt.say("#{badges.join("\n")}\n")
1818
+ end
1819
+
1820
+ def composer_attachment_badges(input, attachments = [])
1821
+ references = Array(attachments)
1822
+ references = Kward::ImageAttachments.references_from_text(input) if references.empty?
1823
+ references.map { |reference| attachment_badge_text(reference) }
1824
+ end
1825
+
1826
+ def composer_attachment_parser(input)
1827
+ Kward::ImageAttachments.extract_references_from_text(input)
1828
+ end
1829
+
1830
+ def submitted_display_input(input)
1831
+ input.respond_to?(:display_input) ? input.display_input : nil
1832
+ end
1833
+
1834
+ def attachment_badge_text(reference)
1835
+ status = reference[:status] || reference["status"]
1836
+ label = reference[:label] || reference["label"] || "image"
1837
+ if status == :missing || status.to_s == "missing"
1838
+ "[image?] #{label} not found"
1839
+ else
1840
+ media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
1841
+ size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
1842
+ "[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
1843
+ end
1844
+ end
1845
+
1846
+ def format_attachment_size(bytes)
1847
+ value = bytes.to_i
1848
+ return "" unless value.positive?
1849
+ return "#{value} B" if value < 1024
1850
+
1851
+ units = %w[KB MB GB]
1852
+ size = value.to_f / 1024
1853
+ unit = units.shift
1854
+ while size >= 1024 && units.any?
1855
+ size /= 1024
1856
+ unit = units.shift
1857
+ end
1858
+ formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
1859
+ "#{formatted} #{unit}"
1860
+ end
1861
+
1862
+ def agent_display_options(display_input)
1863
+ display_input.nil? ? {} : { display_input: display_input }
1864
+ end
1865
+
1866
+ def print_pasted_images(input, image_parts: nil)
1867
+ parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
1868
+ parts.each do |part|
1869
+ sequence = Kward::ImageAttachments.terminal_image_sequence(part)
1870
+ next unless sequence
1871
+
1872
+ if @prompt.respond_to?(:say_visual)
1873
+ @prompt.say_visual(sequence)
1874
+ else
1875
+ @prompt.say(sequence)
1876
+ end
1877
+ end
1878
+ end
1879
+
1880
+ def print_block_delta(label, delta)
1881
+ if prompt_interface?
1882
+ @prompt.start_stream_block(label)
1883
+ @prompt.write_delta(delta)
1884
+ else
1885
+ start_stream_block(label)
1886
+ print delta
1887
+ $stdout.flush
1888
+ end
1889
+ end
1890
+
1891
+ def print_retry(event)
1892
+ message = retry_message(event)
1893
+ if prompt_interface?
1894
+ @prompt.start_stream_block("Retry")
1895
+ @prompt.write_delta("#{message}\n")
1896
+ @prompt.finish_stream_block
1897
+ else
1898
+ start_stream_block("Retry")
1899
+ puts message
1900
+ $stdout.flush
1901
+ @stream_block = nil
1902
+ end
1903
+ end
1904
+
1905
+ def retry_message(event)
1906
+ RetryMessage.format(event)
1907
+ end
1908
+
1909
+ def print_tool_call(tool_call)
1910
+ if prompt_interface?
1911
+ @prompt.start_stream_block("Tool")
1912
+ @prompt.write_delta("#{tool_command(tool_call)}\n")
1913
+ @prompt.finish_stream_block
1914
+ else
1915
+ start_stream_block("Tool")
1916
+ puts tool_command(tool_call)
1917
+ $stdout.flush
1918
+ @stream_block = nil
1919
+ end
1920
+ end
1921
+
1922
+ def print_tool_result(tool_call, content, line_limit: nil)
1923
+ summary = tool_result_summary(tool_call, content)
1924
+ summary = limit_tool_output_lines(summary, line_limit) if line_limit
1925
+ if prompt_interface?
1926
+ @prompt.start_stream_block("Tool output")
1927
+ @prompt.write_delta(summary)
1928
+ @prompt.write_delta("\n") unless summary.end_with?("\n")
1929
+ @prompt.finish_stream_block
1930
+ else
1931
+ start_stream_block("Tool output")
1932
+ print summary
1933
+ puts unless summary.end_with?("\n")
1934
+ $stdout.flush
1935
+ @stream_block = nil
1936
+ end
1937
+ end
1938
+
1939
+ def tool_result_summary(tool_call, content)
1940
+ name = tool_call_name(tool_call)
1941
+ args = tool_call_args(tool_call)
1942
+ text = content.to_s
1943
+ return error_tool_summary(name, args, text) if text.start_with?("Error:", "Declined:")
1944
+
1945
+ case name
1946
+ when "read_file"
1947
+ read_file_summary(args, text)
1948
+ when "write_file", "edit_file"
1949
+ file_change_summary(name, args, text)
1950
+ when "run_shell_command"
1951
+ shell_command_summary(args, text)
1952
+ when "web_search"
1953
+ web_search_summary(args, text)
1954
+ else
1955
+ generic_tool_summary(name, text)
1956
+ end
1957
+ end
1958
+
1959
+ def limit_tool_output_lines(content, line_limit)
1960
+ lines = content.to_s.lines
1961
+ return content.to_s if lines.length <= line_limit
1962
+
1963
+ kept_lines = lines.first(line_limit - 1).join
1964
+ omitted_lines = lines.length - (line_limit - 1)
1965
+ suffix = omitted_lines == 1 ? "line" : "lines"
1966
+ notice = "...[truncated #{omitted_lines} #{suffix}]"
1967
+ kept_lines.end_with?("\n") || kept_lines.empty? ? "#{kept_lines}#{notice}" : "#{kept_lines}\n#{notice}"
1968
+ end
1969
+
1970
+ def read_file_summary(args, content)
1971
+ path = args["path"] || args[:path] || "(unknown path)"
1972
+ "read_file: #{path}\n#{content.lines.count} lines, #{content.bytesize} bytes"
1973
+ end
1974
+
1975
+ def file_change_summary(name, args, content)
1976
+ path = args["path"] || args[:path] || path_from_tool_result(content) || "(unknown path)"
1977
+ concise = content.lines.first.to_s.strip
1978
+ concise = "completed" if concise.empty?
1979
+ "#{name}: #{path}\n#{concise}"
1980
+ end
1981
+
1982
+ def shell_command_summary(args, content)
1983
+ command = args["command"] || args[:command] || ""
1984
+ lines = ["run_shell_command: #{command}".strip]
1985
+ lines << "Exit status: #{shell_exit_status(content) || "unknown"}"
1986
+ stdout = shell_section(content, "STDOUT")
1987
+ stderr = shell_section(content, "STDERR")
1988
+ lines << compact_stream_summary("stdout", stdout) unless stdout.empty?
1989
+ lines << compact_stream_summary("stderr", stderr) unless stderr.empty?
1990
+ lines.join("\n")
1991
+ end
1992
+
1993
+ def web_search_summary(args, content)
1994
+ queries = Array(args["queries"] || args[:queries]).map(&:to_s)
1995
+ queries = web_search_queries_from_content(content) if queries.empty?
1996
+ counts = web_search_result_counts(content)
1997
+ lines = ["web_search"]
1998
+ queries.each do |query|
1999
+ lines << "#{query}: #{counts.fetch(query, 0)} result(s)"
2000
+ end
2001
+ lines << "#{web_search_total_count(content)} result(s)" if queries.empty?
2002
+ lines.join("\n")
2003
+ end
2004
+
2005
+ def error_tool_summary(name, args, content)
2006
+ path = args["path"] || args[:path]
2007
+ command = args["command"] || args[:command]
2008
+ context = path || command
2009
+ [name, context, content.lines.first.to_s.strip].compact.reject(&:empty?).join("\n")
2010
+ end
2011
+
2012
+ def generic_tool_summary(name, content)
2013
+ text = content.to_s
2014
+ return "#{name}: #{text}" if text.length <= RESTORED_TOOL_OUTPUT_LIMIT
2015
+
2016
+ "#{name}: #{text[0, RESTORED_TOOL_OUTPUT_LIMIT]}\n...[truncated #{text.length - RESTORED_TOOL_OUTPUT_LIMIT} bytes]"
2017
+ end
2018
+
2019
+ def compact_stream_summary(label, text)
2020
+ summary = text.strip
2021
+ summary = summary[0, 500] + "\n...[truncated #{summary.length - 500} chars]" if summary.length > 500
2022
+ "#{label} (#{text.bytesize} bytes):#{summary.empty? ? "" : "\n#{summary}"}"
2023
+ end
2024
+
2025
+ def shell_exit_status(content)
2026
+ content.match(/^Exit status: ([^\n]+)/)&.[](1)
2027
+ end
2028
+
2029
+ def shell_section(content, name)
2030
+ match = content.match(/^#{Regexp.escape(name)}:\n(.*?)(?=\nSTD(?:OUT|ERR):\n|\z)/m)
2031
+ match ? match[1] : ""
2032
+ end
2033
+
2034
+ def web_search_queries_from_content(content)
2035
+ content.scan(/^## Query: (.+)$/).flatten
2036
+ end
2037
+
2038
+ def web_search_result_counts(content)
2039
+ counts = {}
2040
+ current_query = nil
2041
+ content.each_line do |line|
2042
+ if (match = line.match(/^## Query: (.+)$/))
2043
+ current_query = match[1]
2044
+ counts[current_query] ||= 0
2045
+ elsif current_query && line.match?(/^\d+\. /)
2046
+ counts[current_query] += 1
2047
+ end
2048
+ end
2049
+ counts
2050
+ end
2051
+
2052
+ def web_search_total_count(content)
2053
+ content.each_line.count { |line| line.match?(/^\d+\. /) }
2054
+ end
2055
+
2056
+ def path_from_tool_result(content)
2057
+ content.match(/\b(?:to|file|Edited)\s+([^:\n]+?)(?:\s|:|\z)/)&.[](1)
2058
+ end
2059
+
2060
+ def tool_call_name(tool_call)
2061
+ ToolCall.name(tool_call) || "unknown_tool"
2062
+ end
2063
+
2064
+ def tool_call_args(tool_call)
2065
+ ToolCall.arguments(tool_call)
2066
+ end
2067
+
2068
+ def start_stream_block(label)
2069
+ return if @stream_block == label
2070
+
2071
+ puts if @stream_block
2072
+ puts "\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}"
2073
+ @stream_block = label
2074
+ end
2075
+
2076
+ def finish_stream_block
2077
+ if prompt_interface?
2078
+ @prompt.finish_stream_block
2079
+ else
2080
+ puts if @stream_block
2081
+ @stream_block = nil
2082
+ end
2083
+ end
2084
+
2085
+ def colored(text, *styles)
2086
+ ANSI.colorize(text, *styles, enabled: @color_enabled)
2087
+ end
2088
+
2089
+ def transcript_label(label)
2090
+ label == "Assistant" ? assistant_prompt_name : label
2091
+ end
2092
+
2093
+ def label_color(label)
2094
+ case label
2095
+ when "Reasoning"
2096
+ :yellow
2097
+ when "Assistant", "Kward"
2098
+ :green
2099
+ when "Tool"
2100
+ :magenta
2101
+ when "Tool output"
2102
+ :cyan
2103
+ else
2104
+ :blue
2105
+ end
2106
+ end
2107
+
2108
+ def tool_command(tool_call)
2109
+ name = tool_call_name(tool_call)
2110
+ args = tool_call_args(tool_call)
2111
+
2112
+ if name == "run_shell_command"
2113
+ args["command"] || args[:command] || ""
2114
+ elsif args.empty?
2115
+ name.to_s
2116
+ else
2117
+ "#{name} #{JSON.dump(args)}"
2118
+ end
2119
+ end
2120
+
2121
+ end
2122
+ end