kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -22,6 +22,24 @@ module Kward
22
22
  when "redraw"
23
23
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
24
24
  [true, nil]
25
+ when "git"
26
+ handle_git_command(agent)
27
+ [true, nil]
28
+ when "files"
29
+ open_project_files_browser
30
+ [true, nil]
31
+ when "shell"
32
+ run_ekwsh(agent)
33
+ [true, nil]
34
+ when "workers"
35
+ unless experimental_workers_enabled?
36
+ runtime_output("Workers are experimental. Start Kward with --experimental-workers to enable /workers.")
37
+ return [true, nil]
38
+ end
39
+
40
+ [true, handle_workers_command(argument, agent, session_store)]
41
+ when "tab"
42
+ [true, handle_tab_command(argument, session_store)]
25
43
  when "settings"
26
44
  configure_settings(agent.conversation)
27
45
  [true, nil]
@@ -93,7 +111,9 @@ module Kward
93
111
  run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
94
112
  [true, nil]
95
113
  else
96
- if plugin_command_for(name)
114
+ if interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
115
+ run_interactive_command(name, argument, agent)
116
+ elsif plugin_command_for(name)
97
117
  run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
98
118
  else
99
119
  [false, nil]
@@ -105,9 +125,17 @@ module Kward
105
125
  PromptCommands.parse(command) || [nil, ""]
106
126
  end
107
127
 
128
+ def open_project_files_browser
129
+ if @prompt.respond_to?(:open_project_browser)
130
+ @prompt.open_project_browser
131
+ else
132
+ runtime_output("The project file browser is only available in the interactive prompt.")
133
+ end
134
+ end
135
+
108
136
  # Writes the status output for the terminal CLI flow.
109
137
  def print_status
110
- lines = [STATUS_MESSAGE]
138
+ lines = ["Kward status"]
111
139
  lines << ""
112
140
  lines << auto_compaction_status_line
113
141
  if @active_session
@@ -136,6 +164,391 @@ module Kward
136
164
  nil
137
165
  end
138
166
 
167
+ def handle_workers_command(argument, agent, session_store)
168
+ action, value = argument.to_s.strip.split(/\s+/, 2)
169
+ replacement_agent = case action
170
+ when nil, ""
171
+ open_worker_menu(agent, session_store)
172
+ when "list"
173
+ open_worker_list(agent, session_store)
174
+ when "new", "do"
175
+ prompt_for_worker_request(agent, value)
176
+ nil
177
+ else
178
+ runtime_output("Usage: /workers | /workers new | /workers list")
179
+ nil
180
+ end
181
+ replacement_agent?(replacement_agent) ? replacement_agent : nil
182
+ end
183
+
184
+ def worker_store
185
+ @worker_store ||= Workers::Store.new
186
+ end
187
+
188
+ def worker_manager(agent)
189
+ workspace_root = interactive_workspace_root(agent)
190
+ return @worker_manager if @worker_manager && @worker_manager_workspace_root == workspace_root
191
+
192
+ @worker_manager_workspace_root = workspace_root
193
+ @worker_manager = Workers::Manager.new(
194
+ client_factory: -> { Client.new },
195
+ prompt: @prompt,
196
+ workspace_root: workspace_root,
197
+ session_store: interactive_session_store(agent),
198
+ provider: current_model_provider,
199
+ model: current_model_id,
200
+ reasoning_effort: current_reasoning_effort,
201
+ write_lock: (@worker_write_lock ||= Workers::WriteLock.new),
202
+ worker_store: worker_store,
203
+ write_lane_available: -> { !@foreground_turn_active }
204
+ )
205
+ end
206
+
207
+ def open_worker_menu(agent, session_store)
208
+ return runtime_output("Usage: /workers | /workers new | /workers list") unless @prompt.respond_to?(:select)
209
+
210
+ choice = @prompt.select(
211
+ "Workers",
212
+ ["New worker", "List workers"],
213
+ title: "Workers",
214
+ custom: false
215
+ )
216
+ case choice
217
+ when "New worker"
218
+ prompt_for_worker_request(agent)
219
+ when "List workers"
220
+ open_worker_list(agent, session_store)
221
+ end
222
+ end
223
+
224
+ def prompt_for_worker_request(agent, topic = nil)
225
+ topic = @prompt.ask("Worker task>") if topic.to_s.strip.empty? && @prompt.respond_to?(:ask)
226
+ send_worker_request(topic, agent) unless topic.to_s.strip.empty?
227
+ end
228
+
229
+ def send_worker_request(topic, agent)
230
+ if topic.to_s.strip.empty?
231
+ runtime_output("Usage: /workers new <task>")
232
+ return
233
+ end
234
+
235
+ worker = worker_manager(agent).start(role: "request", prompt: topic)
236
+ runtime_output("Worker #{worker.id} started: #{worker.title}")
237
+ end
238
+
239
+ def open_worker_list(agent, session_store, title: "Workers", empty_message: "No workers in the pipeline.")
240
+ return runtime_output(empty_message) unless @prompt.respond_to?(:select)
241
+
242
+ jobs = worker_jobs(agent)
243
+ if jobs.empty?
244
+ runtime_output(empty_message)
245
+ return
246
+ end
247
+
248
+ labels = jobs.map { |job| worker_choice_label(job) }
249
+ choice = @prompt.select("Select worker", labels, title: title, custom: false)
250
+ return unless choice
251
+
252
+ selected = jobs[labels.index(choice)]
253
+ open_worker_actions(selected, agent, session_store) if selected
254
+ end
255
+
256
+ def worker_jobs(agent)
257
+ runtime_worker_ids = @worker_manager ? @worker_manager.list.map(&:id) : []
258
+ persisted_workers = worker_store.list.reject { |job| runtime_worker_ids.include?(job["id"]) }
259
+ live_workers = @worker_manager ? @worker_manager.list.map(&:to_h) : []
260
+ [implementation_worker_job(agent)].compact + persisted_workers + live_workers
261
+ end
262
+
263
+ def implementation_worker_job(agent)
264
+ remember_implementation_worker(agent) if implementation_agent?(agent)
265
+ path = @implementation_worker_session_path || @active_session&.path
266
+ return nil if path.to_s.empty?
267
+
268
+ {
269
+ "id" => "implementation",
270
+ "title" => @implementation_worker_title || @active_session&.name || "Implementation",
271
+ "role" => "implementation",
272
+ "status" => implementation_agent?(agent) ? "active" : "idle",
273
+ "session_path" => path
274
+ }
275
+ end
276
+
277
+ def implementation_agent?(agent)
278
+ @active_worker_role.to_s.empty? || @active_worker_role == "implementation"
279
+ end
280
+
281
+ def remember_implementation_worker(agent)
282
+ return unless @active_session&.path
283
+ return unless implementation_agent?(agent)
284
+
285
+ @implementation_worker_session_path = @active_session.path
286
+ @implementation_worker_title = @active_session.name || "Implementation"
287
+ end
288
+
289
+ def open_worker_actions(job, _agent, session_store)
290
+ return open_implementation_actions(job, session_store) if job["id"] == "implementation"
291
+
292
+ open_background_worker_actions(job, session_store)
293
+ end
294
+
295
+ def open_implementation_actions(job, session_store)
296
+ actions = ["Show", "Back to list"]
297
+ choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
298
+ case choice
299
+ when "Show"
300
+ load_implementation_session(session_store, job)
301
+ when "Back to list"
302
+ open_worker_list(nil, session_store)
303
+ end
304
+ end
305
+
306
+ def handle_request_worker_input(input, agent, session_store)
307
+ return [false, nil] unless @active_worker_role == "request"
308
+
309
+ worker = visible_request_worker(agent)
310
+ return [false, nil] unless worker
311
+
312
+ text = input.to_s.strip
313
+ return [true, nil] if text.empty?
314
+ return [true, proceed_request_worker(worker.to_h, agent, session_store)] if proceed_request_input?(text)
315
+
316
+ runtime_output("Worker #{worker.id} is a read-only request review. Reply yes/proceed to queue implementation, or use /workers to switch workers.")
317
+ [true, nil]
318
+ end
319
+
320
+ def visible_request_worker(agent)
321
+ worker = @visible_worker
322
+ return worker if worker&.role == "request" && worker.status == "ready"
323
+
324
+ id = @visible_worker_id.to_s
325
+ return nil if id.empty? || id == "implementation"
326
+
327
+ worker = @worker_manager&.find(id)
328
+ return worker if worker&.role == "request" && worker.status == "ready"
329
+
330
+ job = worker_store.find(id)
331
+ return nil unless job && job["role"] == "request" && job["status"] == "ready"
332
+ return nil if job["session_path"].to_s.empty? || !session_matches_agent?(job["session_path"], agent)
333
+
334
+ Workers::Worker.new(
335
+ id: job.fetch("id"),
336
+ title: job.fetch("title"),
337
+ role: job.fetch("role"),
338
+ workspace_root: job["workspace_root"] || current_workspace_root,
339
+ status: job.fetch("status"),
340
+ prompt: job["prompt"]
341
+ ).tap { |restored| restored.update_status("ready", report: job["report"], error: job["error"]) }
342
+ end
343
+
344
+ def session_matches_agent?(path, agent)
345
+ return true unless agent.respond_to?(:conversation)
346
+ return false unless @active_session&.path
347
+
348
+ File.expand_path(path) == File.expand_path(@active_session.path)
349
+ end
350
+
351
+ def proceed_request_input?(input)
352
+ input.downcase.strip.match?(/\A(?:y|yes|yeah|yep|sure|ok|okay|go ahead|proceed|continue|implement|do it|please do|make it so)\b/)
353
+ end
354
+
355
+ def proceed_request_worker(job, agent, session_store)
356
+ return runtime_output("Worker #{job.fetch('id')} is not ready to proceed.") unless request_ready?(job)
357
+
358
+ release_implementation_writer
359
+ manager = worker_manager(agent || build_session_agent_for_worker(job, session_store))
360
+ worker = manager.continue(
361
+ job.fetch("id"),
362
+ role: "implementation",
363
+ prompt: implementation_prompt_for_request(job),
364
+ title: "Implement #{job.fetch('title')}"
365
+ )
366
+ runtime_output("Worker #{worker.id} queued from request #{job.fetch('id')}: #{worker.title}")
367
+ wait_for_worker_session(worker)
368
+ load_worker_session(session_store, worker.session.path, worker.to_h, worker: worker) if worker.session&.path
369
+ rescue StandardError => e
370
+ runtime_output("Error: #{e.message}")
371
+ nil
372
+ end
373
+
374
+ def wait_for_worker_session(worker, timeout: 1.0)
375
+ deadline = Time.now + timeout
376
+ until worker.session&.path || Time.now >= deadline
377
+ sleep 0.02
378
+ end
379
+ end
380
+
381
+ def build_session_agent_for_worker(job, session_store)
382
+ conversation = Conversation.new(workspace_root: job["workspace_root"] || session_store&.cwd || current_workspace_root)
383
+ build_worker_agent(conversation, role: "request")
384
+ end
385
+
386
+ def request_ready?(job)
387
+ job["role"] == "request" && job["status"] == "ready"
388
+ end
389
+
390
+ def implementation_prompt_for_request(job)
391
+ <<~PROMPT
392
+ The user reviewed and approved this Kward request. Continue in the write-capable implementation lane.
393
+
394
+ Original request:
395
+ #{job["prompt"]}
396
+
397
+ Request review:
398
+ #{job["report"].to_s.empty? ? "No saved review text is available. Use the request session transcript for context if needed." : job["report"]}
399
+
400
+ Implement the approved next step. Make the smallest correct change, preserve existing style, and run focused verification when practical.
401
+ If you change files, commit the changes and report the commit hash. If no file changes are needed, explain why.
402
+ PROMPT
403
+ end
404
+
405
+ def open_background_worker_actions(job, session_store)
406
+ actions = ["Show"]
407
+ actions << "Proceed" if request_ready?(job)
408
+ actions << "Cancel" if %w[queued running].include?(job["status"])
409
+ actions << "Dismiss"
410
+ actions << "Back to list"
411
+ choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
412
+ case choice
413
+ when "Show"
414
+ worker = @worker_manager&.find(job.fetch("id"))
415
+ path = job["session_path"] || worker&.session&.path
416
+ return runtime_output("Worker #{job.fetch('id')} session is not ready yet.") unless path
417
+
418
+ load_worker_session(session_store, path, job, worker: worker)
419
+ when "Proceed"
420
+ proceed_request_worker(job, nil, session_store)
421
+ when "Cancel"
422
+ @worker_manager&.cancel(job.fetch("id"))
423
+ runtime_output("Worker #{job.fetch('id')} cancelled.")
424
+ when "Dismiss"
425
+ dismiss_worker(job.fetch("id"))
426
+ runtime_output("Worker #{job.fetch('id')} dismissed.")
427
+ when "Back to list"
428
+ open_worker_list(nil, session_store)
429
+ end
430
+ end
431
+
432
+ def dismiss_worker(id)
433
+ @worker_manager&.archive(id)
434
+ rescue ArgumentError
435
+ nil
436
+ ensure
437
+ worker_store.archive(id)
438
+ end
439
+
440
+ def load_implementation_session(session_store, job)
441
+ return runtime_output("Implementation session unavailable.") unless session_store
442
+
443
+ stop_live_worker_view
444
+ @active_worker_role = "implementation"
445
+ set_visible_worker("implementation", status: "active")
446
+ load_session(session_store, job.fetch("session_path"), message: "Showing implementation worker")
447
+ rescue StandardError => e
448
+ runtime_output("Error: #{e.message}")
449
+ nil
450
+ end
451
+
452
+ def worker_choice_label(job)
453
+ error = job["status"] == "failed" && !job["error"].to_s.empty? ? " — #{job['error']}" : ""
454
+ "#{job.fetch('id')} [#{job.fetch('role')}/#{job.fetch('status')}] #{job.fetch('title')}#{error}"
455
+ end
456
+
457
+ def load_worker_session(session_store, path, job, worker: nil)
458
+ unless session_store
459
+ runtime_output(worker_report_text(job))
460
+ return nil
461
+ end
462
+
463
+ release_implementation_writer
464
+ agent = load_session(session_store, path, message: "Showing worker #{job.fetch('id')}")
465
+ release_implementation_writer
466
+ role = visible_session_role(job)
467
+ agent = build_worker_agent(agent.conversation, role: role)
468
+ @active_worker_role = role
469
+ set_visible_worker(job.fetch("id"), status: job["status"], worker: worker)
470
+ @prompt.redraw if @prompt.respond_to?(:redraw)
471
+ start_live_worker_view(worker, agent) if live_worker?(worker)
472
+ agent
473
+ rescue StandardError => e
474
+ runtime_output("Error: #{e.message}")
475
+ nil
476
+ end
477
+
478
+ def visible_session_role(job)
479
+ return "read_only" if job["id"] != "implementation" && job["role"] == "implementation"
480
+
481
+ job["role"] || "request"
482
+ end
483
+
484
+ def live_worker?(worker)
485
+ worker && %w[queued running].include?(worker.status)
486
+ end
487
+
488
+ def start_live_worker_view(worker, agent)
489
+ return unless prompt_interface?
490
+
491
+ stop_live_worker_view
492
+ renderer = live_worker_renderer(worker)
493
+ @live_worker_view = Workers::LiveView.new(worker: worker, agent: agent, renderer: renderer).start
494
+ @prompt.redraw if @prompt.respond_to?(:redraw)
495
+ runtime_output("Watching worker #{worker.id}; the view will update until it finishes.")
496
+ end
497
+
498
+ def stop_live_worker_view
499
+ @live_worker_view&.stop
500
+ @live_worker_view = nil
501
+ end
502
+
503
+ def live_worker_renderer(worker)
504
+ markdown_chunks = []
505
+ stream_state = {
506
+ streamed: false,
507
+ last_flush: monotonic_now,
508
+ stream_block_open: false,
509
+ markdown_streams: {},
510
+ defer_assistant_streaming: false
511
+ }
512
+ lambda do |event, agent|
513
+ if event == :flush
514
+ flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
515
+ @prompt.redraw if @prompt.respond_to?(:redraw)
516
+ next
517
+ end
518
+
519
+ notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
520
+ handle_live_worker_event(event, markdown_chunks, stream_state)
521
+ flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
522
+ rescue StandardError => e
523
+ runtime_output("Worker view error: #{e.message}")
524
+ end
525
+ end
526
+
527
+ def handle_live_worker_event(event, markdown_chunks, stream_state)
528
+ case event
529
+ when Events::AssistantMessage
530
+ return if stream_state[:streamed]
531
+
532
+ render_assistant_message(event.message)
533
+ else
534
+ handle_interactive_event(event, markdown_chunks, stream_state)
535
+ end
536
+ end
537
+
538
+ def worker_finished?(worker)
539
+ %w[ready failed cancelled archived].include?(worker.status)
540
+ end
541
+
542
+ def worker_report_text(job)
543
+ lines = ["Worker #{job.fetch('id')} [#{job.fetch('status')}] #{job.fetch('title')}", ""]
544
+ if job["report"].to_s.empty?
545
+ lines << (job["error"].to_s.empty? ? "No report yet." : "Error: #{job['error']}")
546
+ else
547
+ lines << job["report"]
548
+ end
549
+ lines.join("\n")
550
+ end
551
+
139
552
  end
140
553
  end
141
554
  end