kward 0.67.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
data/lib/kward/cli.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "cli_transcript_formatter"
13
13
  require_relative "model/context_usage"
14
14
  require_relative "events"
15
15
  require_relative "export_path"
16
+ require_relative "auth/anthropic_oauth"
16
17
  require_relative "auth/github_oauth"
17
18
  require_relative "auth/openrouter_api_key"
18
19
  require_relative "image_attachments"
@@ -35,7 +36,23 @@ require_relative "tools/tool_call"
35
36
  require_relative "tools/registry"
36
37
  require_relative "telemetry/stats"
37
38
  require_relative "workspace"
38
-
39
+ require_relative "cli/commands"
40
+ require_relative "cli/auth_commands"
41
+ require_relative "cli/doctor"
42
+ require_relative "cli/stats"
43
+ require_relative "cli/runtime_helpers"
44
+ require_relative "cli/slash_commands"
45
+ require_relative "cli/memory_commands"
46
+ require_relative "cli/settings"
47
+ require_relative "cli/sessions"
48
+ require_relative "cli/compaction"
49
+ require_relative "cli/rendering"
50
+ require_relative "cli/prompt_interface"
51
+ require_relative "cli/plugins"
52
+ require_relative "cli/interactive_turn"
53
+ require_relative "cli/tool_summaries"
54
+
55
+ # Namespace for the Kward CLI agent runtime.
39
56
  module Kward
40
57
  # Command-line interface for interactive chat, one-shot prompts, login,
41
58
  # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
@@ -49,6 +66,22 @@ module Kward
49
66
  BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
50
67
  BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
51
68
 
69
+ include CLI::Commands
70
+ include CLI::AuthCommands
71
+ include CLI::Doctor
72
+ include CLI::Stats
73
+ include CLI::RuntimeHelpers
74
+ include CLI::SlashCommands
75
+ include CLI::MemoryCommands
76
+ include CLI::Settings
77
+ include CLI::Sessions
78
+ include CLI::CompactionCommands
79
+ include CLI::Rendering
80
+ include CLI::PromptInterfaceSupport
81
+ include CLI::Plugins
82
+ include CLI::InteractiveTurn
83
+ include CLI::ToolSummaries
84
+
52
85
  def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
53
86
  @argv = argv
54
87
  @stdin = stdin
@@ -110,11 +143,6 @@ module Kward
110
143
  return
111
144
  end
112
145
 
113
- if @argv == ["--install-starter-pack"]
114
- install_starter_pack
115
- return
116
- end
117
-
118
146
  if @argv.first == "auth"
119
147
  handle_auth_command(@argv[1..] || [])
120
148
  return
@@ -207,94 +235,18 @@ module Kward
207
235
  conversation: conversation
208
236
  )
209
237
  answer = agent.ask(input) do |event|
210
- case event
211
- when Events::ReasoningDelta
212
- streamed = true
213
- append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
214
- when Events::AssistantDelta
215
- streamed = true
216
- assistant_streamed = true
217
- append_markdown_delta(markdown_chunks, "Assistant", event.delta)
218
- when Events::Retry
219
- streamed = true
220
- flush_markdown_deltas(markdown_chunks)
221
- print_retry(event)
222
- when Events::ToolCall
223
- streamed = true
224
- flush_markdown_deltas(markdown_chunks)
225
- print_tool_call(event.tool_call)
226
- when Events::ToolResult
227
- streamed = true
228
- flush_markdown_deltas(markdown_chunks)
229
- print_tool_result(event.tool_call, event.content)
230
- end
238
+ result = render_blocking_turn_event(event, markdown_chunks)
239
+ streamed = true if result
240
+ assistant_streamed = true if result == :assistant_streamed
231
241
  end
232
242
  flush_markdown_deltas(markdown_chunks) if streamed
233
243
  assistant_streamed ? "" : render_markdown_transcript(answer)
234
244
  end
235
245
 
236
- def handle_auth_command(arguments)
237
- if help_option_arguments?(arguments)
238
- print_command_help("auth")
239
- return
240
- end
241
-
242
- case arguments
243
- when ["status"]
244
- print_auth_status
245
- when ["logout"]
246
- logout_auth
247
- else
248
- raise ArgumentError, command_usage("auth")
249
- end
250
- end
251
-
252
- def print_auth_status
253
- config = safely_read_config.to_h
254
- lines = ["#{colored("Auth Status", :green, :bold)}", ""]
255
- lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
256
- lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
257
- lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
258
- @prompt.say lines.join("\n")
259
- end
260
-
261
- def auth_status_line(label, configured, location)
262
- status = configured ? :ok : :warning
263
- message = configured ? "configured" : "not configured"
264
- "#{doctor_mark(status)} #{label}: #{message} (#{location})"
265
- end
266
-
267
- def logout_auth
268
- removed = []
269
- [OpenAIOAuth.default_auth_path, GithubOAuth.default_auth_path].each do |path|
270
- next unless File.exist?(path)
271
246
 
272
- File.delete(path)
273
- removed << path
274
- end
275
- removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
276
247
 
277
- if removed.empty?
278
- @prompt.say "No saved credentials found."
279
- else
280
- @prompt.say "Removed #{removed.length} saved credential#{removed.length == 1 ? "" : "s"}."
281
- end
282
- end
283
248
 
284
- def login(provider: nil, oauth: nil)
285
- provider = provider.to_s.downcase
286
- if provider == "openrouter"
287
- auth = oauth || OpenRouterAPIKey.new
288
- path = auth.login(prompt: @prompt)
289
- @prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
290
- return
291
- end
292
249
 
293
- oauth ||= provider == "github" ? GithubOAuth.new : OpenAIOAuth.new
294
- path = oauth.login(prompt: @prompt)
295
- name = provider == "github" ? "GitHub" : "OpenAI"
296
- @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
297
- end
298
250
 
299
251
  def interactive_loop(agent: nil)
300
252
  setup_interactive_prompt
@@ -303,7 +255,7 @@ module Kward
303
255
  if session_store && agent.nil?
304
256
  agent = resume_last_session(session_store) || build_new_session_agent(session_store)
305
257
  elsif session_store
306
- @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
258
+ @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
307
259
  reset_session_diff
308
260
  @active_session.attach(agent.conversation)
309
261
  else
@@ -374,2709 +326,5 @@ module Kward
374
326
  @stdin.read.strip
375
327
  end
376
328
 
377
- private
378
-
379
- def help_command?
380
- ["help", "--help", "-h"].include?(@argv.first) && @argv.length <= 2
381
- end
382
-
383
- def version_command?
384
- ["version", "--version", "-v"].include?(@argv.first) && @argv.length == 1
385
- end
386
-
387
- def help_option_arguments?(arguments)
388
- arguments.length == 1 && ["help", "--help", "-h"].include?(arguments.first)
389
- end
390
-
391
- def one_shot_prompt_argument
392
- prompt = @argv.join(" ").strip
393
- prompt.empty? ? nil : prompt
394
- end
395
-
396
- def print_command_help(command_name = nil)
397
- if command_name.to_s.empty? || ["--help", "-h"].include?(command_name)
398
- print_help
399
- return
400
- end
401
-
402
- help = command_help[command_name]
403
- raise ArgumentError, "Unknown command: #{command_name}" unless help
404
-
405
- @prompt.say render_command_help(command_name, help)
406
- end
407
-
408
- def print_help
409
- command = ->(text) { colored(text, :green, :bold) }
410
- option = ->(text) { colored(text, :cyan) }
411
- heading = ->(text) { colored(text, :blue, :bold) }
412
-
413
- @prompt.say <<~HELP.rstrip
414
- #{colored("Kward", :green, :bold)} - an extendable CLI coding agent
415
-
416
- #{heading.call("Usage")}
417
- #{command.call("kward")} Start an interactive chat
418
- #{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
419
- #{command.call("kward login")} Sign in or save provider credentials
420
- #{command.call("kward auth status")} Show saved credential status
421
- #{command.call("kward init")} Install starter prompts and AGENTS.md
422
- #{command.call("kward doctor")} Check local Kward setup
423
- #{command.call("kward pan")} Start Pan mode web UI
424
- #{command.call("kward rpc")} Start the experimental JSON-RPC backend
425
-
426
- #{heading.call("Commands")}
427
- #{command.call("help")} Show this help
428
- #{command.call("version")} Show the installed Kward version
429
- #{command.call("login")} [openrouter|github] Sign in with OpenAI, OpenRouter, or GitHub
430
- #{command.call("auth status|logout")} Show or clear saved credentials
431
- #{command.call("init")} Install starter prompts and AGENTS.md
432
- #{command.call("doctor")} Check local Kward setup
433
- #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
434
- #{command.call("pan")} Start Pan mode web UI
435
- #{command.call("rpc")} Run the JSON-RPC backend for UI clients
436
-
437
- #{heading.call("Options")}
438
- #{option.call("--working-directory=PATH")} Run Kward from PATH
439
- #{option.call("--help")}, #{option.call("-h")} Show this help
440
- #{option.call("--version")}, #{option.call("-v")} Show the installed version
441
-
442
- #{heading.call("Examples")}
443
- #{command.call("kward")}
444
- #{command.call("kward")} #{option.call('"Review this diff"')}
445
- #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
446
- #{command.call("kward login openrouter")}
447
- #{command.call("kward stats tokens today --bucket hour")}
448
-
449
- Command names take precedence. Anything else is sent as a one-shot prompt.
450
- HELP
451
- end
452
-
453
- def command_help
454
- {
455
- "help" => {
456
- usage: "kward help [command]",
457
- description: "Show the top-level command overview or help for one command.",
458
- examples: ["kward help", "kward help pan"]
459
- },
460
- "version" => {
461
- usage: "kward version",
462
- description: "Show the installed Kward version.",
463
- examples: ["kward version", "kward --version"]
464
- },
465
- "login" => {
466
- usage: "kward login [openrouter|github]",
467
- description: "Sign in with OpenAI, OpenRouter, or GitHub.",
468
- examples: ["kward login", "kward login openrouter", "kward login github"]
469
- },
470
- "auth" => {
471
- usage: "kward auth status|logout",
472
- description: "Show or clear saved provider credentials without printing secrets.",
473
- examples: ["kward auth status", "kward auth logout"]
474
- },
475
- "init" => {
476
- usage: "kward init",
477
- description: "Install starter prompts and base AGENTS.md into your config directory.",
478
- examples: ["kward init"]
479
- },
480
- "doctor" => {
481
- usage: "kward doctor",
482
- description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
483
- examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
484
- },
485
- "stats" => {
486
- usage: "kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]",
487
- description: "Export local token telemetry as CSV.",
488
- examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
489
- },
490
- "pan" => {
491
- usage: "kward pan",
492
- description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
493
- examples: ["kward pan", "kward --working-directory ~/code/project pan"]
494
- },
495
- "rpc" => {
496
- usage: "kward rpc",
497
- description: "Start the experimental JSON-RPC backend for UI clients.",
498
- examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
499
- }
500
- }
501
- end
502
-
503
- def render_command_help(name, help)
504
- heading = ->(text) { colored(text, :blue, :bold) }
505
- command = ->(text) { colored(text, :green, :bold) }
506
-
507
- lines = [
508
- "#{command.call(name)} - #{help.fetch(:description)}",
509
- "",
510
- heading.call("Usage"),
511
- " #{command.call(help.fetch(:usage))}"
512
- ]
513
- examples = help.fetch(:examples, [])
514
- if examples.any?
515
- lines << ""
516
- lines << heading.call("Examples")
517
- examples.each { |example| lines << " #{command.call(example)}" }
518
- end
519
- lines.join("\n")
520
- end
521
-
522
- def command_usage(name)
523
- "Usage: #{command_help.fetch(name).fetch(:usage)}"
524
- end
525
-
526
- def print_version
527
- @prompt.say "kward #{VERSION}"
528
- end
529
-
530
- def print_doctor
531
- lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
532
- doctor_checks.each do |check|
533
- lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
534
- end
535
- @prompt.say lines.join("\n")
536
- end
537
-
538
- def doctor_checks
539
- config = safely_read_config
540
- [
541
- doctor_config_check,
542
- doctor_config_json_check(config),
543
- doctor_directory_check("Config directory", ConfigFiles.config_dir),
544
- doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
545
- doctor_workspace_check,
546
- doctor_model_check,
547
- doctor_auth_check(config),
548
- doctor_pan_check(config),
549
- { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
550
- ]
551
- end
552
-
553
- def safely_read_config
554
- ConfigFiles.read_config
555
- rescue StandardError
556
- nil
557
- end
558
-
559
- def doctor_config_check
560
- path = ConfigFiles.config_path
561
- if File.exist?(path)
562
- readable = File.readable?(path)
563
- return { status: readable ? :ok : :error, label: "Config", message: readable ? path : "not readable: #{path}" }
564
- end
565
-
566
- { status: :warning, label: "Config", message: "not found: #{path}" }
567
- end
568
-
569
- def doctor_config_json_check(config)
570
- return { status: :error, label: "Config JSON", message: "invalid or unreadable" } unless config.is_a?(Hash)
571
-
572
- { status: :ok, label: "Config JSON", message: "valid" }
573
- end
574
-
575
- def doctor_directory_check(label, path, create: false)
576
- FileUtils.mkdir_p(path, mode: 0o700) if create
577
- if Dir.exist?(path) && File.writable?(path)
578
- { status: :ok, label: label, message: "writable: #{path}" }
579
- elsif Dir.exist?(path)
580
- { status: :error, label: label, message: "not writable: #{path}" }
581
- else
582
- { status: :error, label: label, message: "missing: #{path}" }
583
- end
584
- rescue StandardError => e
585
- { status: :error, label: label, message: e.message }
586
- end
587
-
588
- def doctor_workspace_check
589
- root = current_workspace_root
590
- return { status: :ok, label: "Workspace", message: root } if Dir.exist?(root) && File.directory?(root)
591
-
592
- { status: :error, label: "Workspace", message: "not a directory: #{root}" }
593
- end
594
-
595
- def doctor_model_check
596
- provider = @client.current_provider if @client.respond_to?(:current_provider)
597
- model = @client.current_model if @client.respond_to?(:current_model)
598
- parts = [provider, model].compact.map(&:to_s).reject(&:empty?)
599
- return { status: :ok, label: "Model", message: parts.join(" / ") } if parts.any?
600
-
601
- { status: :warning, label: "Model", message: "not configured" }
602
- rescue StandardError => e
603
- { status: :warning, label: "Model", message: e.message }
604
- end
605
-
606
- def doctor_auth_check(config)
607
- openai_auth = OpenAIOAuth.default_auth_path
608
- github_auth = GithubOAuth.default_auth_path
609
- has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
610
- paths = []
611
- paths << "OpenAI OAuth" if File.exist?(openai_auth)
612
- paths << "GitHub OAuth" if File.exist?(github_auth)
613
- paths << "OpenRouter API key" if has_openrouter
614
- return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
615
-
616
- { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
617
- end
618
-
619
- def doctor_pan_check(config)
620
- pan = config.to_h["pan_mode"] || config.to_h["panMode"] || {}
621
- if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
622
- { status: :ok, label: "Pan mode", message: "credentials configured" }
623
- else
624
- { status: :warning, label: "Pan mode", message: "username/password not configured" }
625
- end
626
- end
627
-
628
- def doctor_mark(status)
629
- case status
630
- when :ok
631
- colored("✓", :green, :bold)
632
- when :warning
633
- colored("!", :yellow, :bold)
634
- else
635
- colored("✗", :red, :bold)
636
- end
637
- end
638
-
639
- def install_starter_pack
640
- result = StarterPackInstaller.install
641
- installed_count = result.installed.length
642
- skipped_count = result.skipped.length
643
- @prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
644
- @prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
645
- rescue StandardError => e
646
- warn "Failed to install starter pack: #{e.message}"
647
- exit 1
648
- end
649
-
650
- def pan_mode?
651
- ["pan", "--pan-mode"].include?(@argv.first)
652
- end
653
-
654
- def export_token_stats(arguments)
655
- options = parse_token_stats_options(arguments)
656
- csv = TelemetryStats.new.token_usage_csv(options[:range], bucket: options[:bucket])
657
- if options[:output]
658
- File.write(options[:output], csv)
659
- else
660
- $stdout.write(csv)
661
- end
662
- rescue ArgumentError => e
663
- warn e.message
664
- warn "Usage: kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]"
665
- exit 1
666
- end
667
-
668
- def parse_token_stats_options(arguments)
669
- remaining = []
670
- bucket = nil
671
- output = nil
672
- index = 0
673
- while index < arguments.length
674
- argument = arguments[index]
675
- case argument
676
- when "--bucket"
677
- index += 1
678
- raise ArgumentError, "Missing value for --bucket" if index >= arguments.length
679
-
680
- bucket = arguments[index]
681
- when /\A--bucket=(.+)\z/
682
- bucket = Regexp.last_match(1)
683
- when "--output"
684
- index += 1
685
- raise ArgumentError, "Missing value for --output" if index >= arguments.length
686
-
687
- output = arguments[index]
688
- when /\A--output=(.+)\z/
689
- output = Regexp.last_match(1)
690
- else
691
- remaining << argument
692
- end
693
- index += 1
694
- end
695
- { range: remaining.join(" "), bucket: bucket, output: output }
696
- end
697
-
698
- def extract_global_options(arguments)
699
- remaining = []
700
- index = 0
701
- while index < arguments.length
702
- argument = arguments[index]
703
- case argument
704
- when "--"
705
- @prompt_delimited = true
706
- remaining.concat(arguments[(index + 1)..] || [])
707
- break
708
- when "--working-directory"
709
- index += 1
710
- raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
711
-
712
- @working_directory = expanded_working_directory(arguments[index])
713
- when /\A--working-directory=(.*)\z/
714
- @working_directory = expanded_working_directory(Regexp.last_match(1))
715
- else
716
- remaining << argument
717
- end
718
- index += 1
719
- end
720
- remaining
721
- end
722
-
723
- def expanded_working_directory(path)
724
- value = path.to_s.strip
725
- raise ArgumentError, "Missing value for --working-directory" if value.empty?
726
-
727
- expanded = File.expand_path(value)
728
- raise ArgumentError, "Working directory does not exist: #{expanded}" unless Dir.exist?(expanded)
729
- raise ArgumentError, "Working directory is not a directory: #{expanded}" unless File.directory?(expanded)
730
-
731
- expanded
732
- end
733
-
734
- def with_working_directory
735
- return yield unless @working_directory
736
-
737
- Dir.chdir(@working_directory) { yield }
738
- end
739
-
740
- def interactive_session_store(agent)
741
- return @session_store if @session_store
742
- return nil if agent
743
-
744
- SessionStore.new
745
- end
746
-
747
- def resume_last_session(session_store)
748
- return nil unless session_auto_resume_enabled?
749
-
750
- path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
751
- return nil if path.to_s.empty?
752
-
753
- @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
754
- reset_session_diff(@active_session.path)
755
- track_session(@active_session)
756
- @resumed_last_session = true
757
- build_interactive_agent(conversation)
758
- rescue StandardError
759
- nil
760
- end
761
-
762
- def render_resumed_last_session_transcript(conversation)
763
- restore_prompt_transcript do
764
- @prompt.say("\nResumed session: #{@active_session.path}\n")
765
- render_conversation_transcript(conversation)
766
- end
767
- end
768
-
769
- def remember_active_session(session_store)
770
- return unless session_store&.respond_to?(:remember_last_session)
771
- return unless @active_session&.path && File.file?(@active_session.path)
772
-
773
- session_store.remember_last_session(@active_session)
774
- end
775
-
776
- def build_new_session_agent(session_store)
777
- @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
778
- reset_session_diff
779
- conversation = new_conversation(workspace_root: session_store.cwd)
780
- @active_session.attach(conversation)
781
- build_interactive_agent(conversation)
782
- end
783
-
784
- def track_session(session)
785
- @cleanup_sessions << session if session
786
- session
787
- end
788
-
789
- def reset_session_diff(path = nil)
790
- @session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
791
- end
792
-
793
- def update_session_diff(content, tool_call: nil)
794
- return unless mutation_tool_call?(tool_call)
795
- return unless @session_diff&.add_tool_result(content)
796
-
797
- @prompt.redraw if @prompt.respond_to?(:redraw)
798
- end
799
-
800
- def mutation_tool_call?(tool_call)
801
- ["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
802
- end
803
-
804
- def cleanup_unused_sessions
805
- @cleanup_sessions.reverse_each do |session|
806
- session.delete_if_unused if session.respond_to?(:delete_if_unused)
807
- end
808
- @cleanup_sessions.clear
809
- end
810
-
811
- def cleanup_replaced_session(previous_session)
812
- return unless previous_session
813
- return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
814
-
815
- previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
816
- end
817
-
818
- def new_conversation(workspace_root: current_workspace_root)
819
- Conversation.new(workspace_root: workspace_root, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
820
- end
821
-
822
- def update_assistant_prompt(conversation)
823
- @assistant_prompt = assistant_prompt_label(conversation)
824
- @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
825
- @assistant_prompt
826
- end
827
-
828
- def assistant_prompt_label(conversation)
829
- label = ConfigFiles.active_persona_label(workspace_root: conversation.workspace_root, model: conversation.model)
830
- "#{label || "Assistant"}>"
831
- rescue StandardError
832
- "Assistant>"
833
- end
834
-
835
- def assistant_prompt_name
836
- assistant_output_prompt.delete_suffix(">")
837
- end
838
-
839
- def assistant_output_prompt
840
- @assistant_prompt || "Assistant>"
841
- end
842
-
843
- def build_interactive_agent(conversation)
844
- conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
845
- workspace = configured_workspace(root: conversation.workspace_root)
846
- tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
847
- @footer_conversation = conversation
848
- @footer_tool_registry = tool_registry
849
- Agent.new(
850
- client: @client,
851
- tool_registry: tool_registry,
852
- conversation: conversation
853
- )
854
- end
855
-
856
- def handle_interactive_shell_command(input, agent)
857
- command = input.to_s.sub(/\A!\s*/, "")
858
- if command.strip.empty?
859
- @prompt.say("\nShell command is required after !\n")
860
- return true
861
- end
862
-
863
- run_busy_local_command_and_requeue(activity: "running") do
864
- result = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
865
- @prompt.say("\n#{colored("Shell>", :green, :bold)} #{command}\n#{result}\n")
866
- end
867
- true
868
- end
869
-
870
- def shell_command_input?(input)
871
- input.to_s.start_with?("!")
872
- end
873
-
874
- def configured_workspace(root: current_workspace_root)
875
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
876
- end
877
-
878
- def workspace_guardrails_enabled?
879
- ConfigFiles.workspace_guardrails_enabled?(safely_read_config.to_h)
880
- end
881
-
882
- def interactive_workspace_root(agent)
883
- conversation = agent.conversation if agent.respond_to?(:conversation)
884
- return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
885
-
886
- current_workspace_root
887
- end
888
-
889
- def handle_local_slash_command(command, agent, session_store)
890
- name, argument = parse_slash_command(command)
891
- case name
892
- when "status"
893
- run_busy_local_command_and_requeue { print_status }
894
- [true, nil]
895
- when "stats"
896
- run_busy_local_command_and_requeue { print_stats(argument) }
897
- [true, nil]
898
- when "memory"
899
- activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
900
- run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
901
- [true, nil]
902
- when "redraw"
903
- run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
904
- [true, nil]
905
- when "settings"
906
- configure_settings(agent.conversation)
907
- [true, nil]
908
- when "login"
909
- login_interactively
910
- [true, nil]
911
- when "model"
912
- models = run_busy_local_command_and_requeue { normalized_available_models }
913
- configure_model(agent.conversation, models: models)
914
- [true, nil]
915
- when "openrouter/catalog"
916
- run_busy_local_command_and_requeue { print_openrouter_catalog }
917
- [true, nil]
918
- when "reasoning"
919
- configure_reasoning(agent.conversation)
920
- [true, nil]
921
- when "reload"
922
- run_busy_local_command_and_requeue { reload_plugins(agent.conversation) }
923
- [true, nil]
924
- when "new"
925
- [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
926
- when "resume"
927
- [true, run_busy_local_command_and_requeue do
928
- path = argument.to_s.strip
929
- path = select_session_path(session_store) if session_store && path.empty?
930
- resume_session(session_store, path)
931
- end]
932
- when "name"
933
- run_busy_local_command_and_requeue { rename_session(argument) }
934
- [true, nil]
935
- when "clone"
936
- [true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
937
- when "tree"
938
- [true, run_busy_local_command_and_requeue { navigate_session_tree(session_store) }]
939
- when "copy"
940
- run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
941
- [true, nil]
942
- when "export"
943
- run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
944
- [true, nil]
945
- when "compact"
946
- run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
947
- [true, nil]
948
- else
949
- return run_plugin_command(name, argument, agent) if plugin_command_for(name)
950
-
951
- [false, nil]
952
- end
953
- end
954
-
955
- def parse_slash_command(command)
956
- PromptCommands.parse(command) || [nil, ""]
957
- end
958
-
959
- def memory_summarize_command?(argument)
960
- subcommand, = argument.to_s.strip.split(/\s+/, 2)
961
- ["summarize", "learn"].include?(subcommand)
962
- end
963
-
964
- def print_status
965
- lines = [STATUS_MESSAGE]
966
- lines << ""
967
- lines << auto_compaction_status_line
968
- if @active_session
969
- lines << "Session: #{@active_session.name || @active_session.id}"
970
- lines << "File: #{@active_session.path}"
971
- end
972
- lines.compact!
973
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{lines.join("\n")}\n")
974
- end
975
-
976
- def auto_compaction_status_line
977
- settings = Kward::Compaction::Settings.from_config
978
- return "Auto-compaction: disabled" unless settings.enabled
979
-
980
- context_window = composer_context_window
981
- return "Auto-compaction: enabled, unknown context window" unless context_window.to_i.positive?
982
-
983
- reserve_tokens = Kward::Compactor.auto_compaction_reserve_tokens(
984
- context_window: context_window,
985
- configured_reserve_tokens: settings.reserve_tokens
986
- )
987
- percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
988
- "Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
989
- rescue StandardError => e
990
- warn "Auto-compaction status unavailable: #{e.message}"
991
- nil
992
- end
993
-
994
- def print_stats(argument)
995
- result = TelemetryStats.new.collect(argument)
996
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{TelemetryStats.format(result)}\n")
997
- rescue ArgumentError => e
998
- message = e.message == TelemetryStats::USAGE ? e.message : "#{e.message}\n#{TelemetryStats::USAGE}"
999
- @prompt.say("\n#{message}\n")
1000
- end
1001
-
1002
- def handle_memory_command(argument, agent)
1003
- subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
1004
- manager = Memory::Manager.new
1005
- case subcommand
1006
- when "enable"
1007
- manager.enable
1008
- agent.conversation.refresh_system_message!
1009
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
1010
- when "disable"
1011
- manager.disable
1012
- agent.conversation.memory_context = nil
1013
- agent.conversation.refresh_system_message!
1014
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
1015
- when "auto-summary"
1016
- case rest.to_s.strip
1017
- when "enable", "on"
1018
- manager.auto_summary_enable
1019
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
1020
- when "disable", "off"
1021
- manager.auto_summary_disable
1022
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
1023
- else
1024
- @prompt.say("\nUsage: /memory auto-summary enable|disable\n")
1025
- end
1026
- when "core"
1027
- record = manager.add_core(unquote_argument(rest))
1028
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
1029
- when "add"
1030
- record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
1031
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
1032
- when "list"
1033
- @prompt.say("\n#{format_memory_list(manager.hierarchy(workspace_root: agent.conversation.workspace_root))}\n")
1034
- when "forget"
1035
- forgotten = manager.forget_memory(rest.to_s.strip)
1036
- @prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
1037
- when "promote"
1038
- record = manager.promote_memory(rest.to_s.strip)
1039
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted memory #{record["id"]}.\n")
1040
- when "relax"
1041
- record = manager.relax_core(rest.to_s.strip, workspace_root: agent.conversation.workspace_root)
1042
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Relaxed memory #{record["id"]}.\n")
1043
- when "inspect"
1044
- @prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
1045
- when "why"
1046
- explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
1047
- @prompt.say("\n#{format_memory_why(explanation)}\n")
1048
- when "summarize", "learn"
1049
- records = summarize_memory(agent.conversation, manager: manager)
1050
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
1051
- else
1052
- @prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize\n")
1053
- end
1054
- rescue StandardError => e
1055
- @prompt.say("\nMemory command failed: #{e.message}\n")
1056
- end
1057
-
1058
- def summarize_memory(conversation, manager: Memory::Manager.new)
1059
- records = manager.summarize_conversation(conversation, client: @client)
1060
- @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
1061
- records
1062
- end
1063
-
1064
- def unquote_argument(text)
1065
- value = text.to_s.strip
1066
- value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
1067
- value
1068
- end
1069
-
1070
- def format_memory_list(memories)
1071
- sections = [
1072
- ["Global Core Memories:", Array(memories["global_core"])],
1073
- ["Workspace Core Memories:", Array(memories["workspace_core"])],
1074
- ["Workspace Soft Memories:", Array(memories["workspace_soft"])]
1075
- ]
1076
-
1077
- sections.flat_map do |heading, records|
1078
- lines = [heading]
1079
- records.each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
1080
- lines << "- none" if records.empty?
1081
- lines
1082
- end.join("\n")
1083
- end
1084
-
1085
- def format_memory_why(explanation)
1086
- reasons = Array(explanation["reasons"])
1087
- return explanation["message"] || "No memories were retrieved." if reasons.empty?
1088
-
1089
- (["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
1090
- end
1091
-
1092
- def run_busy_local_command(activity: "loading")
1093
- return yield unless prompt_interface?
1094
-
1095
- queued_inputs = []
1096
- result = nil
1097
- error = nil
1098
- @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
1099
-
1100
- worker = Thread.new do
1101
- result = yield
1102
- rescue StandardError => e
1103
- error = e
1104
- end
1105
-
1106
- while worker.alive?
1107
- collect_queued_input(queued_inputs)
1108
- sleep 0.02
1109
- end
1110
- worker.join
1111
- drain_queued_input(queued_inputs)
1112
- raise error if error
1113
-
1114
- [result, queued_inputs]
1115
- ensure
1116
- @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
1117
- end
1118
-
1119
- def run_busy_local_command_and_requeue(activity: "loading")
1120
- return yield unless prompt_interface?
1121
-
1122
- result, queued_inputs = run_busy_local_command(activity: activity) { yield }
1123
- queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
1124
- result
1125
- end
1126
-
1127
- def current_workspace_root
1128
- return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
1129
- return @working_directory if @working_directory
1130
-
1131
- Dir.pwd
1132
- end
1133
-
1134
- def configure_settings(conversation = nil)
1135
- unless settings_overlay_available?
1136
- @prompt.say("\nSettings overlay is unavailable in this prompt.\n")
1137
- return
1138
- end
1139
-
1140
- loop do
1141
- selected = @prompt.select("Settings category", settings_category_choices, title: "Settings")
1142
- category = selected_settings_category(selected)
1143
- break unless category
1144
-
1145
- break if category == "done"
1146
-
1147
- handle_settings_category(category, conversation)
1148
- end
1149
- rescue StandardError => e
1150
- @prompt.say("\nSettings error: #{e.message}\n")
1151
- end
1152
-
1153
- def settings_category_choices
1154
- [
1155
- "Model & Reasoning",
1156
- "Accounts",
1157
- "Memory",
1158
- "Interface",
1159
- "Tools & Search",
1160
- "Context & Compaction",
1161
- "Personalization",
1162
- "Logging",
1163
- "Advanced",
1164
- "Done"
1165
- ]
1166
- end
1167
-
1168
- def selected_settings_category(selected)
1169
- text = selected.to_s.downcase
1170
- return nil if text.empty?
1171
- return "done" if text.start_with?("done")
1172
- return "model" if text.start_with?("model")
1173
- return "accounts" if text.start_with?("accounts")
1174
- return "memory" if text.start_with?("memory")
1175
- return "interface" if text.start_with?("interface")
1176
- return "tools" if text.start_with?("tools")
1177
- return "context" if text.start_with?("context")
1178
- return "personalization" if text.start_with?("personalization")
1179
- return "logging" if text.start_with?("logging")
1180
- return "advanced" if text.start_with?("advanced")
1181
-
1182
- nil
1183
- end
1184
-
1185
- def handle_settings_category(category, conversation)
1186
- case category
1187
- when "model"
1188
- configure_model_settings(conversation)
1189
- when "accounts"
1190
- configure_account_settings
1191
- when "memory"
1192
- configure_memory_settings(conversation)
1193
- when "interface"
1194
- configure_interface_settings
1195
- when "tools"
1196
- configure_tools_settings
1197
- when "context"
1198
- configure_context_settings
1199
- when "personalization"
1200
- configure_personalization_settings(conversation)
1201
- when "logging"
1202
- configure_logging_settings
1203
- when "advanced"
1204
- show_advanced_settings
1205
- end
1206
- end
1207
-
1208
- def configure_model_settings(conversation)
1209
- selected = @prompt.select("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], title: "Settings")
1210
- case selected.to_s.downcase
1211
- when /\Aprovider/
1212
- configure_provider(conversation)
1213
- when /\Adefault model/
1214
- configure_model(conversation)
1215
- when /\Areasoning effort/
1216
- configure_reasoning(conversation)
1217
- end
1218
- end
1219
-
1220
- def configure_provider(conversation)
1221
- selected = @prompt.select("Provider", provider_choices, title: "Settings")
1222
- provider = selected_provider(selected)
1223
- return unless provider
1224
-
1225
- ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
1226
- reload_client_config
1227
- refresh_conversation_runtime(conversation)
1228
- @prompt.redraw if @prompt.respond_to?(:redraw)
1229
- end
1230
-
1231
- def provider_choices
1232
- current = current_model_provider
1233
- ["Codex", "OpenRouter", "Copilot"].map do |provider|
1234
- label = provider.dup
1235
- label += " (current)" if provider == current
1236
- label
1237
- end
1238
- end
1239
-
1240
- def selected_provider(selected)
1241
- text = selected.to_s.downcase
1242
- return "Codex" if text.start_with?("codex")
1243
- return "OpenRouter" if text.start_with?("openrouter")
1244
- return "Copilot" if text.start_with?("copilot")
1245
-
1246
- nil
1247
- end
1248
-
1249
- def configure_account_settings
1250
- selected = @prompt.select("Accounts", account_setting_choices, title: "Settings")
1251
- case selected.to_s.downcase
1252
- when /\Aopenai/
1253
- login(provider: "openai")
1254
- reload_client_config
1255
- when /\Agithub/
1256
- login(provider: "github")
1257
- reload_client_config
1258
- when /\Aopenrouter/
1259
- login(provider: "openrouter")
1260
- reload_client_config
1261
- when /\Astatus/
1262
- print_auth_status
1263
- end
1264
- end
1265
-
1266
- def account_setting_choices
1267
- config = safely_read_config.to_h
1268
- [
1269
- "OpenAI login (#{File.exist?(OpenAIOAuth.default_auth_path) ? "configured" : "not configured"})",
1270
- "GitHub login (#{File.exist?(GithubOAuth.default_auth_path) ? "configured" : "not configured"})",
1271
- "OpenRouter API key (#{openrouter_key_status(config)})",
1272
- "Status",
1273
- "Back"
1274
- ]
1275
- end
1276
-
1277
- def openrouter_key_status(config)
1278
- return "configured via environment" unless ENV["OPENROUTER_API_KEY"].to_s.empty?
1279
-
1280
- config["openrouter_api_key"].to_s.empty? ? "not configured" : "configured"
1281
- end
1282
-
1283
- def configure_memory_settings(conversation)
1284
- selected = @prompt.select("Memory", memory_setting_choices, title: "Settings")
1285
- case selected.to_s.downcase
1286
- when /\Aenable memory/, /\Adisable memory/
1287
- set_memory_enabled(!memory_enabled?)
1288
- conversation&.refresh_system_message!
1289
- @prompt.say("\nMemory #{memory_enabled? ? "enabled" : "disabled"}.\n")
1290
- when /\Aenable auto-summary/, /\Adisable auto-summary/
1291
- set_memory_auto_summary_enabled(!memory_auto_summary_enabled?)
1292
- @prompt.say("\nMemory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.\n")
1293
- when /\Amanage/
1294
- @prompt.say("\nUse /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.\n")
1295
- end
1296
- end
1297
-
1298
- def memory_setting_choices
1299
- [
1300
- "#{memory_enabled? ? "Disable" : "Enable"} memory (currently #{on_off(memory_enabled?)})",
1301
- "#{memory_auto_summary_enabled? ? "Disable" : "Enable"} auto-summary (currently #{on_off(memory_auto_summary_enabled?)})",
1302
- "Manage memories with /memory",
1303
- "Back"
1304
- ]
1305
- end
1306
-
1307
- def memory_enabled?
1308
- memory = safely_read_config.to_h["memory"]
1309
- memory.is_a?(Hash) && memory["enabled"] == true
1310
- end
1311
-
1312
- def memory_auto_summary_enabled?
1313
- memory = safely_read_config.to_h["memory"]
1314
- memory.is_a?(Hash) && memory["auto_summary"] == true
1315
- end
1316
-
1317
- def set_memory_enabled(enabled)
1318
- update_nested_config("memory", "enabled" => enabled)
1319
- end
1320
-
1321
- def set_memory_auto_summary_enabled(enabled)
1322
- update_nested_config("memory", "auto_summary" => enabled)
1323
- end
1324
-
1325
- def configure_interface_settings
1326
- selected = @prompt.select("Interface", interface_setting_choices, title: "Settings")
1327
- case selected.to_s.downcase
1328
- when /\Aoverlay alignment/
1329
- settings = ConfigFiles.overlay_settings
1330
- alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
1331
- return unless alignment
1332
-
1333
- @prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("alignment" => alignment))
1334
- when /\Aoverlay width/
1335
- settings = ConfigFiles.overlay_settings
1336
- width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
1337
- return unless width
1338
-
1339
- @prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("width" => width))
1340
- when /\Ashow busy help/, /\Ahide busy help/
1341
- set_composer_busy_help(!composer_busy_help?)
1342
- @prompt.say("\nBusy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
1343
- when /\Ashow startup banner/, /\Ahide startup banner/
1344
- set_banner_enabled(!banner_enabled?)
1345
- @prompt.say("\nStartup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
1346
- when /\Aenable session auto-resume/, /\Adisable session auto-resume/
1347
- set_session_auto_resume_enabled(!session_auto_resume_enabled?)
1348
- @prompt.say("\nSession auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.\n")
1349
- end
1350
- end
1351
-
1352
- def interface_setting_choices
1353
- settings = ConfigFiles.overlay_settings
1354
- [
1355
- "Overlay alignment (#{settings["alignment"]})",
1356
- "Overlay width (#{settings["width"]})",
1357
- "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
1358
- "#{banner_enabled? ? "Hide" : "Show"} startup banner (currently #{on_off(banner_enabled?)})",
1359
- "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
1360
- "Back"
1361
- ]
1362
- end
1363
-
1364
- def composer_busy_help?
1365
- ConfigFiles.composer_busy_help?(safely_read_config.to_h)
1366
- end
1367
-
1368
- def banner_enabled?
1369
- ConfigFiles.banner_enabled?(safely_read_config.to_h)
1370
- end
1371
-
1372
- def session_auto_resume_enabled?
1373
- ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
1374
- end
1375
-
1376
- def set_composer_busy_help(enabled)
1377
- update_nested_config("composer", "busy_help" => enabled)
1378
- end
1379
-
1380
- def set_banner_enabled(enabled)
1381
- update_nested_config("banner", "enabled" => enabled)
1382
- end
1383
-
1384
- def set_session_auto_resume_enabled(enabled)
1385
- update_nested_config("sessions", "auto_resume" => enabled)
1386
- end
1387
-
1388
- def configure_tools_settings
1389
- selected = @prompt.select("Tools & Search", tools_setting_choices, title: "Settings")
1390
- case selected.to_s.downcase
1391
- when /\Aenable web search/, /\Adisable web search/
1392
- set_web_search_enabled(!web_search_enabled?)
1393
- @prompt.say("\nWeb search #{web_search_enabled? ? "enabled" : "disabled"}.\n")
1394
- when /\Aweb search provider/
1395
- configure_web_search_provider
1396
- when /\Aallow model-provider/, /\Adisallow model-provider/
1397
- set_web_search_allow_model_providers(!web_search_allow_model_providers?)
1398
- @prompt.say("\nModel-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.\n")
1399
- when /\Aenable workspace guardrails/, /\Adisable workspace guardrails/
1400
- set_workspace_guardrails_enabled(!workspace_guardrails_enabled?)
1401
- @prompt.say("\nWorkspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.\n")
1402
- end
1403
- end
1404
-
1405
- def tools_setting_choices
1406
- [
1407
- "#{web_search_enabled? ? "Disable" : "Enable"} web search (currently #{on_off(web_search_enabled?)})",
1408
- "Web search provider (#{web_search_provider})",
1409
- "#{web_search_allow_model_providers? ? "Disallow" : "Allow"} model-provider web search (currently #{on_off(web_search_allow_model_providers?)})",
1410
- "#{workspace_guardrails_enabled? ? "Disable" : "Enable"} workspace guardrails (currently #{on_off(workspace_guardrails_enabled?)})",
1411
- "Back"
1412
- ]
1413
- end
1414
-
1415
- def configure_web_search_provider
1416
- providers = %w[auto exa perplexity gemini legacy]
1417
- selected = @prompt.select("Web search provider", providers.map { |provider| provider == web_search_provider ? "#{provider} (current)" : provider }, title: "Settings")
1418
- provider = providers.find { |value| selected.to_s.downcase.start_with?(value) }
1419
- return unless provider
1420
-
1421
- update_nested_config("web_search", "provider" => provider)
1422
- end
1423
-
1424
- def web_search_config
1425
- config = safely_read_config.to_h
1426
- value = config["web_search"] || config["webSearch"] || config["web_research"] || config["webResearch"]
1427
- value.is_a?(Hash) ? value : {}
1428
- end
1429
-
1430
- def web_search_enabled?
1431
- web_search_config["enabled"] != false
1432
- end
1433
-
1434
- def web_search_provider
1435
- web_search_config["provider"].to_s.empty? ? "auto" : web_search_config["provider"].to_s
1436
- end
1437
-
1438
- def web_search_allow_model_providers?
1439
- web_search_config["allow_model_providers"] == true
1440
- end
1441
-
1442
- def set_web_search_enabled(enabled)
1443
- update_nested_config("web_search", "enabled" => enabled)
1444
- end
1445
-
1446
- def set_web_search_allow_model_providers(enabled)
1447
- update_nested_config("web_search", "allow_model_providers" => enabled)
1448
- end
1449
-
1450
- def set_workspace_guardrails_enabled(enabled)
1451
- update_nested_config("tools", "workspace_guardrails" => enabled)
1452
- end
1453
-
1454
- def configure_context_settings
1455
- selected = @prompt.select("Context & Compaction", context_setting_choices, title: "Settings")
1456
- case selected.to_s.downcase
1457
- when /\Aenable auto-compaction/, /\Adisable auto-compaction/
1458
- set_compaction_enabled(!compaction_enabled?)
1459
- @prompt.say("\nAuto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.\n")
1460
- else
1461
- @prompt.say("\n#{auto_compaction_status_line}\n") if selected.to_s.downcase.start_with?("status")
1462
- end
1463
- end
1464
-
1465
- def context_setting_choices
1466
- [
1467
- "#{compaction_enabled? ? "Disable" : "Enable"} auto-compaction (currently #{on_off(compaction_enabled?)})",
1468
- "Status",
1469
- "Back"
1470
- ]
1471
- end
1472
-
1473
- def compaction_enabled?
1474
- Kward::Compaction::Settings.from_config(safely_read_config.to_h).enabled
1475
- end
1476
-
1477
- def set_compaction_enabled(enabled)
1478
- update_nested_config("compaction", "enabled" => enabled)
1479
- end
1480
-
1481
- def configure_personalization_settings(conversation)
1482
- selected = @prompt.select("Personalization", personalization_setting_choices(conversation), title: "Settings")
1483
- case selected.to_s.downcase
1484
- when /\Adefault persona/
1485
- configure_default_persona(conversation)
1486
- when /\Aactive instructions/
1487
- show_active_instructions_summary(conversation)
1488
- end
1489
- end
1490
-
1491
- def personalization_setting_choices(conversation)
1492
- [
1493
- "Default persona (#{default_persona_label})",
1494
- "Active instructions summary",
1495
- "Back"
1496
- ]
1497
- end
1498
-
1499
- def default_persona_label
1500
- personas = safely_read_config.to_h["personas"]
1501
- value = personas.is_a?(Hash) ? personas["default"] : nil
1502
- value.to_s.empty? ? "none" : value.to_s
1503
- end
1504
-
1505
- def configure_default_persona(conversation)
1506
- config = safely_read_config.to_h
1507
- personas = config["personas"].is_a?(Hash) ? config["personas"] : {}
1508
- entries = ConfigFiles.crew_character_labels(personas)
1509
- choices = entries.map { |key, label| key == personas["default"] ? "#{label} (#{key}, current)" : "#{label} (#{key})" }
1510
- if choices.empty?
1511
- @prompt.say("\nNo configured personas found. Edit #{ConfigFiles.config_path} to add personas.\n")
1512
- return
1513
- end
1514
-
1515
- selected = @prompt.select("Default persona", choices, title: "Settings")
1516
- key = entries.keys.find { |candidate| selected.to_s.include?("(#{candidate}") }
1517
- return unless key
1518
-
1519
- personas = personas.dup
1520
- personas["default"] = key
1521
- ConfigFiles.update_config("personas" => personas)
1522
- conversation&.refresh_system_message!
1523
- @prompt.redraw if @prompt.respond_to?(:redraw)
1524
- end
1525
-
1526
- def show_active_instructions_summary(conversation)
1527
- label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
1528
- lines = ["Active persona: #{label || "none"}"]
1529
- lines << "Global AGENTS.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
1530
- lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
1531
- lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
1532
- @prompt.say("\n#{lines.join("\n")}\n")
1533
- end
1534
-
1535
- def configure_logging_settings
1536
- selected = @prompt.select("Logging", logging_setting_choices, title: "Settings")
1537
- key = logging_key_for_choice(selected)
1538
- return unless key
1539
-
1540
- set_logging_value(key, !logging_enabled?(key))
1541
- @prompt.say("\nLogging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.\n")
1542
- end
1543
-
1544
- def logging_setting_choices
1545
- [
1546
- "#{logging_enabled?("enabled") ? "Disable" : "Enable"} local logging (currently #{on_off(logging_enabled?("enabled"))})",
1547
- "#{logging_enabled?("tokens") ? "Disable" : "Enable"} token logs (currently #{on_off(logging_enabled?("tokens"))})",
1548
- "#{logging_enabled?("performance") ? "Disable" : "Enable"} performance logs (currently #{on_off(logging_enabled?("performance"))})",
1549
- "#{logging_enabled?("tools") ? "Disable" : "Enable"} tool logs (currently #{on_off(logging_enabled?("tools"))})",
1550
- "#{logging_enabled?("errors") ? "Disable" : "Enable"} error logs (currently #{on_off(logging_enabled?("errors"))})",
1551
- "Back"
1552
- ]
1553
- end
1554
-
1555
- def logging_key_for_choice(selected)
1556
- text = selected.to_s.downcase
1557
- return "enabled" if text.include?("local logging")
1558
- return "tokens" if text.include?("token logs")
1559
- return "performance" if text.include?("performance logs")
1560
- return "tools" if text.include?("tool logs")
1561
- return "errors" if text.include?("error logs")
1562
-
1563
- nil
1564
- end
1565
-
1566
- def logging_enabled?(key)
1567
- logging = safely_read_config.to_h["logging"]
1568
- logging.is_a?(Hash) && logging[key] == true
1569
- end
1570
-
1571
- def set_logging_value(key, value)
1572
- update_nested_config("logging", key => value)
1573
- end
1574
-
1575
- def show_advanced_settings
1576
- lines = [
1577
- "Config path: #{ConfigFiles.config_path}",
1578
- "Config directory: #{ConfigFiles.config_dir}",
1579
- "Cache directory: #{ConfigFiles.cache_dir}",
1580
- "Memory directory: #{ConfigFiles.memory_dir}",
1581
- "Plugin directory: #{ConfigFiles.plugin_dir}",
1582
- "Plugins: #{ConfigFiles.plugin_paths.length}",
1583
- "Skills: #{ConfigFiles.skills.length}",
1584
- "Prompt templates: #{ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).length}"
1585
- ]
1586
- @prompt.say("\n#{lines.join("\n")}\n")
1587
- end
1588
-
1589
- def update_nested_config(section, values)
1590
- config = ConfigFiles.read_config
1591
- current = config[section].is_a?(Hash) ? config[section].dup : {}
1592
- config[section] = current.merge(values)
1593
- ConfigFiles.write_config(config)
1594
- config
1595
- end
1596
-
1597
- def on_off(value)
1598
- value ? "on" : "off"
1599
- end
1600
-
1601
- def login_interactively
1602
- unless login_picker_available?
1603
- @prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
1604
- return
1605
- end
1606
-
1607
- selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
1608
- provider = selected_login_provider(selected)
1609
- return unless provider
1610
-
1611
- login(provider: provider)
1612
- reload_client_config
1613
- rescue StandardError => e
1614
- @prompt.say("\nLogin error: #{e.message}\n")
1615
- end
1616
-
1617
- def configure_model(conversation = nil, models: nil)
1618
- unless model_overlay_available?
1619
- @prompt.say("\nModel overlay is unavailable in this prompt.\n")
1620
- return
1621
- end
1622
-
1623
- models ||= normalized_available_models
1624
- choices = model_choices(models)
1625
- selected = @prompt.select("Default model", choices, title: "Models", custom: true)
1626
- return unless selected
1627
-
1628
- provider, model = selected_model(selected, models)
1629
- raise "Model must be a non-empty string" if model.to_s.strip.empty?
1630
-
1631
- ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
1632
- reload_client_config
1633
- refresh_conversation_runtime(conversation)
1634
- @prompt.redraw if @prompt.respond_to?(:redraw)
1635
- rescue StandardError => e
1636
- @prompt.say("\nModel error: #{e.message}\n")
1637
- end
1638
-
1639
- def print_openrouter_catalog
1640
- unless @client.respond_to?(:openrouter_catalog)
1641
- @prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
1642
- return
1643
- end
1644
-
1645
- models = Array(@client.openrouter_catalog)
1646
- if models.empty?
1647
- @prompt.say("\nNo OpenRouter catalog models available.\n")
1648
- else
1649
- ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
1650
- @prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
1651
- end
1652
- rescue StandardError => e
1653
- @prompt.say("\nOpenRouter catalog error: #{e.message}\n")
1654
- end
1655
-
1656
- def configure_reasoning(conversation = nil)
1657
- unless model_overlay_available?
1658
- @prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
1659
- return
1660
- end
1661
-
1662
- choices = ModelInfo::REASONING_EFFORT_CHOICES
1663
- selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
1664
- return unless selected
1665
-
1666
- effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
1667
- raise "Reasoning effort must be low, medium, high, or extra high" unless effort
1668
-
1669
- ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
1670
- reload_client_config
1671
- refresh_conversation_runtime(conversation)
1672
- @prompt.redraw if @prompt.respond_to?(:redraw)
1673
- rescue StandardError => e
1674
- @prompt.say("\nReasoning error: #{e.message}\n")
1675
- end
1676
-
1677
- def login_picker_available?
1678
- @prompt.respond_to?(:select)
1679
- end
1680
-
1681
- def login_provider_choices
1682
- ["OpenAI", "OpenRouter", "GitHub"]
1683
- end
1684
-
1685
- def selected_login_provider(selected)
1686
- case selected.to_s.downcase
1687
- when /\Aopenai\b/
1688
- "openai"
1689
- when /\Aopenrouter\b/
1690
- "openrouter"
1691
- when /\Agithub\b/
1692
- "github"
1693
- end
1694
- end
1695
-
1696
- def model_overlay_available?
1697
- @prompt.respond_to?(:select)
1698
- end
1699
-
1700
- def settings_overlay_available?
1701
- @prompt.respond_to?(:select) && @prompt.respond_to?(:update_overlay_settings)
1702
- end
1703
-
1704
- def choose_overlay_setting(message, choices, values)
1705
- choice = @prompt.select(message, choices, title: "Settings")
1706
- return nil unless choice
1707
-
1708
- values.find { |value| choice.to_s.downcase.start_with?(value) }
1709
- end
1710
-
1711
- def normalized_available_models
1712
- current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
1713
- current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
1714
- current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
1715
- models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
1716
- models.map do |model|
1717
- ModelInfo.normalize(
1718
- model,
1719
- current_provider: current_provider,
1720
- current_model: current_model,
1721
- current_reasoning_effort: current_reasoning
1722
- )
1723
- end
1724
- end
1725
-
1726
- def model_choices(models)
1727
- choices = models.map do |model|
1728
- label = "#{model[:provider]} #{model[:id]}"
1729
- label += " (current)" if model[:current]
1730
- label
1731
- end
1732
- choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
1733
- end
1734
-
1735
- def selected_model(selected, models)
1736
- text = selected.to_s.sub(/ \(current\)\z/, "").strip
1737
- known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
1738
- return [known[:provider], known[:id]] if known
1739
-
1740
- provider, model = text.split(/\s+/, 2)
1741
- if ["Codex", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
1742
- [provider, model.strip]
1743
- else
1744
- [current_model_provider, text]
1745
- end
1746
- end
1747
-
1748
- def reasoning_choices(choices)
1749
- current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
1750
- choices.map do |effort, label|
1751
- text = label.dup
1752
- text += " (current)" if current == effort
1753
- text
1754
- end
1755
- end
1756
-
1757
- def current_model_provider
1758
- @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
1759
- end
1760
-
1761
- def current_model_id
1762
- @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
1763
- end
1764
-
1765
- def current_reasoning_effort
1766
- @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
1767
- end
1768
-
1769
- def reload_client_config
1770
- @client.reload_config if @client.respond_to?(:reload_config)
1771
- end
1772
-
1773
- def refresh_conversation_runtime(conversation)
1774
- return unless conversation&.respond_to?(:update_runtime_context!)
1775
-
1776
- conversation.update_runtime_context!(model: current_model_id, reasoning_effort: current_reasoning_effort)
1777
- @active_session.update_runtime(model: conversation.model, reasoning_effort: conversation.reasoning_effort) if @active_session&.respond_to?(:update_runtime)
1778
- update_assistant_prompt(conversation)
1779
- end
1780
-
1781
- def overlay_alignment_choices(settings)
1782
- ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
1783
- label = alignment.capitalize
1784
- label += " (current)" if settings["alignment"] == alignment
1785
- label
1786
- end
1787
- end
1788
-
1789
- def overlay_width_choices(settings)
1790
- ConfigFiles::OVERLAY_WIDTHS.map do |width|
1791
- label = width.capitalize
1792
- label += " (current)" if settings["width"] == width
1793
- label
1794
- end
1795
- end
1796
-
1797
- def start_new_session(session_store)
1798
- return say_sessions_unavailable unless session_store
1799
-
1800
- previous_session = @active_session
1801
- @active_session = track_session(session_store.create)
1802
- reset_session_diff
1803
- cleanup_replaced_session(previous_session)
1804
- conversation = new_conversation(workspace_root: session_store.cwd)
1805
- @active_session.attach(conversation)
1806
- update_assistant_prompt(conversation)
1807
- clear_prompt_transcript
1808
- print_visual_banner
1809
- build_interactive_agent(conversation)
1810
- end
1811
-
1812
- def resume_session(session_store, argument)
1813
- return say_sessions_unavailable unless session_store
1814
-
1815
- path = argument.to_s.strip
1816
- path = select_session_path(session_store) if path.empty?
1817
- return nil if path.to_s.empty?
1818
-
1819
- previous_session = @active_session
1820
- @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
1821
- reset_session_diff(@active_session.path)
1822
- track_session(@active_session)
1823
- cleanup_replaced_session(previous_session)
1824
- update_assistant_prompt(conversation)
1825
- restore_prompt_transcript do
1826
- @prompt.say("\nResumed session: #{@active_session.path}\n")
1827
- render_conversation_transcript(conversation)
1828
- end
1829
- agent = build_interactive_agent(conversation)
1830
- @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
1831
- agent
1832
- rescue StandardError => e
1833
- @prompt.say("\nError: #{e.message}\n")
1834
- nil
1835
- end
1836
-
1837
-
1838
- def navigate_session_tree(session_store)
1839
- return say_sessions_unavailable unless session_store
1840
- unless @active_session
1841
- @prompt.say("\nNo active persisted session.\n")
1842
- return nil
1843
- end
1844
-
1845
- tree_items = session_tree_items(session_store)
1846
- if tree_items.empty?
1847
- @prompt.say("\nNo session tree entries found.\n")
1848
- return nil
1849
- end
1850
-
1851
- labels_by_entry_id = tree_items.to_h { |item| [item[:entry]["id"].to_s, item[:label]] }
1852
- current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
1853
- initial_index = tree_items.index { |item| item[:entry]["id"].to_s == current_leaf_id.to_s } || tree_items.length - 1
1854
- choice = select_session_tree_entry(labels_by_entry_id.values, initial_index: initial_index)
1855
- return nil unless choice
1856
-
1857
- entry_id = labels_by_entry_id.key(choice)
1858
- entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
1859
- return nil unless entry
1860
-
1861
- selected_text = apply_session_tree_entry(entry)
1862
- @prompt.say("\nMoved session tree position to #{entry["id"]}.\n")
1863
- if selected_text && !selected_text.empty?
1864
- if @prompt.respond_to?(:prefill_input)
1865
- @prompt.prefill_input(selected_text)
1866
- else
1867
- @prompt.say("\nSelected text for editing:\n#{selected_text}\n")
1868
- end
1869
- end
1870
- agent = reload_active_session(session_store)
1871
- @prompt.redraw if @prompt.respond_to?(:redraw)
1872
- agent
1873
- rescue StandardError => e
1874
- @prompt.say("\nSession tree error: #{e.message}\n")
1875
- nil
1876
- end
1877
-
1878
- def select_session_tree_entry(labels, initial_index: 0)
1879
- if @prompt.respond_to?(:select)
1880
- return @prompt.select("Tree>", labels, title: "Session Tree", initial_index: initial_index)
1881
- end
1882
-
1883
- numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
1884
- @prompt.say("\nSession tree:\n#{numbered_labels.join("\n")}\n")
1885
- answer = @prompt.ask("Tree entry number>").to_s.strip
1886
- answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
1887
- end
1888
-
1889
- def apply_session_tree_entry(entry)
1890
- message = entry["message"]
1891
- if message.is_a?(Hash) && message_role(message) == "user"
1892
- target_leaf = entry["parentId"]
1893
- target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
1894
- return full_message_text(message)
1895
- end
1896
-
1897
- @active_session.branch(entry["id"])
1898
- nil
1899
- end
1900
-
1901
- def reload_active_session(session_store)
1902
- @active_session, conversation = session_store.load(
1903
- @active_session.path,
1904
- workspace: configured_workspace(root: session_store.cwd),
1905
- model: current_model_id,
1906
- reasoning_effort: current_reasoning_effort
1907
- )
1908
- reset_session_diff(@active_session.path)
1909
- track_session(@active_session)
1910
- update_assistant_prompt(conversation)
1911
- restore_prompt_transcript do
1912
- render_conversation_transcript(conversation)
1913
- end
1914
- build_interactive_agent(conversation)
1915
- end
1916
-
1917
- def session_tree_items(session_store)
1918
- roots = session_store.session_tree(@active_session.path)
1919
- current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
1920
- SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
1921
- end
1922
-
1923
- def rename_session(argument)
1924
- unless @active_session
1925
- @prompt.say("\nNo active persisted session.\n")
1926
- return
1927
- end
1928
-
1929
- @active_session.rename(argument)
1930
- label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
1931
- @prompt.say("\n#{label}\n")
1932
- end
1933
-
1934
- def clone_session(session_store, agent)
1935
- return say_sessions_unavailable unless session_store
1936
-
1937
- previous_session = @active_session
1938
- @active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
1939
- reset_session_diff(@active_session.path)
1940
- cleanup_replaced_session(previous_session)
1941
- @prompt.say("\nCloned session: #{@active_session.path}\n")
1942
- render_conversation_transcript(agent.conversation)
1943
- agent
1944
- end
1945
-
1946
- def copy_session_text(conversation, argument)
1947
- target = copy_target(argument)
1948
- unless target
1949
- @prompt.say("\nUsage: /copy [last|transcript]\n")
1950
- return
1951
- end
1952
-
1953
- content = copy_target_content(conversation, target)
1954
- if content.to_s.empty?
1955
- @prompt.say("\nNothing to copy.\n")
1956
- return
1957
- end
1958
-
1959
- result = Clipboard.new(output: $stdout).copy(content)
1960
- if result.success?
1961
- @prompt.say("\nCopied #{copy_target_label(target)}.\n")
1962
- else
1963
- @prompt.say("\nCopy failed: #{result.message}.\n")
1964
- end
1965
- end
1966
-
1967
- def copy_target(argument)
1968
- target = argument.to_s.strip.downcase
1969
- target = "last" if target.empty?
1970
- return target if ["last", "transcript"].include?(target)
1971
-
1972
- nil
1973
- end
1974
-
1975
- def full_message_text(message)
1976
- CLITranscriptFormatter.full_text(message)
1977
- end
1978
-
1979
- def copy_target_content(conversation, target)
1980
- case target
1981
- when "last"
1982
- last_assistant_copy_text(conversation)
1983
- when "transcript"
1984
- markdown_transcript(conversation)
1985
- else
1986
- ""
1987
- end
1988
- end
1989
-
1990
- def last_assistant_copy_text(conversation)
1991
- message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
1992
- return "" unless message
1993
-
1994
- CLITranscriptFormatter.content_text(message_content(message))
1995
- end
1996
-
1997
- def copy_target_label(target)
1998
- target == "transcript" ? "transcript" : "last assistant response"
1999
- end
2000
-
2001
- def compact_context(agent, argument)
2002
- result = Compactor.new(
2003
- conversation: agent.conversation,
2004
- client: @client,
2005
- tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
2006
- ).compact(custom_instructions: argument)
2007
- @prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
2008
- render_transcript_block("Assistant", result.summary)
2009
- rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
2010
- @prompt.say("\n#{e.message}\n")
2011
- rescue StandardError => e
2012
- @prompt.say("\nCompaction error: #{e.message}\n")
2013
- end
2014
-
2015
-
2016
- def render_conversation_transcript(conversation)
2017
- tool_calls_by_id = {}
2018
- @prompt.say("\n#{colored("Transcript", :cyan, :bold)}\n")
2019
- conversation.messages.each do |message|
2020
- role = message_role(message)
2021
- next if role == "system"
2022
-
2023
- case role
2024
- when "user"
2025
- print_user_transcript(
2026
- CLITranscriptFormatter.user_transcript_input(message),
2027
- display_input: CLITranscriptFormatter.user_display_text(message),
2028
- attachment_references: CLITranscriptFormatter.image_references(message),
2029
- image_parts: CLITranscriptFormatter.image_parts(message)
2030
- )
2031
- when "assistant"
2032
- render_reasoning(message)
2033
- render_assistant_message(message)
2034
- message_tool_calls(message).each do |tool_call|
2035
- tool_calls_by_id[tool_call_id(tool_call)] = tool_call
2036
- render_tool_call(tool_call)
2037
- end
2038
- when "tool"
2039
- render_tool_message(message, tool_calls_by_id)
2040
- when "compactionSummary"
2041
- render_transcript_block("Compaction summary", message_summary(message))
2042
- else
2043
- render_transcript_block(role.to_s.capitalize, CLITranscriptFormatter.content_text(message_content(message)))
2044
- end
2045
- end
2046
- end
2047
-
2048
- def render_reasoning(message)
2049
- reasoning = CLITranscriptFormatter.reasoning(message)
2050
- render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
2051
- end
2052
-
2053
- def render_assistant_message(message)
2054
- content = CLITranscriptFormatter.content_text(message_content(message))
2055
- return if content.empty?
2056
-
2057
- render_transcript_block("Assistant", content)
2058
- end
2059
-
2060
- def render_tool_message(message, tool_calls_by_id)
2061
- tool_call = tool_calls_by_id[message_tool_call_id(message)] || CLITranscriptFormatter.synthetic_tool_call(message_name(message), message_tool_call_id(message))
2062
- render_tool_result(tool_call, message_content(message).to_s)
2063
- end
2064
-
2065
- def render_tool_call(tool_call)
2066
- if prompt_interface?
2067
- print_tool_call(tool_call)
2068
- else
2069
- @prompt.say("\n#{colored("Tool>", :magenta, :bold)}\n#{tool_command(tool_call)}\n")
2070
- end
2071
- end
2072
-
2073
- def render_tool_result(tool_call, content)
2074
- summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
2075
- if prompt_interface?
2076
- print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
2077
- else
2078
- @prompt.say("\n#{colored("Tool output>", :cyan, :bold)}\n#{summary}\n")
2079
- end
2080
- end
2081
-
2082
- def render_transcript_block(label, content)
2083
- return if content.to_s.empty?
2084
-
2085
- rendered = render_markdown_transcript(content)
2086
- if prompt_interface?
2087
- print_block_delta(label, rendered)
2088
- finish_stream_block
2089
- else
2090
- @prompt.say("\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n#{rendered}\n")
2091
- end
2092
- end
2093
-
2094
- def render_markdown_transcript(content)
2095
- ANSI.markdown(content, enabled: @color_enabled)
2096
- end
2097
-
2098
- def append_markdown_delta(chunks, label, delta)
2099
- text = delta.to_s
2100
- return if text.empty?
2101
-
2102
- if chunks.last&.first == label
2103
- chunks.last[1] << text
2104
- else
2105
- chunks << [label, +text]
2106
- end
2107
- end
2108
-
2109
- def flush_markdown_deltas(chunks, finish: true, streams: nil)
2110
- wrote = false
2111
- entries = ordered_markdown_entries(chunks.dup)
2112
- if finish && streams
2113
- streamed_labels = entries.map(&:first)
2114
- entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
2115
- end
2116
-
2117
- entries.each do |label, content|
2118
- next if content.empty? && !(finish && streams&.key?(label))
2119
-
2120
- rendered = if streams
2121
- streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
2122
- streams[label].render(content, final: finish)
2123
- else
2124
- render_markdown_transcript(content)
2125
- end
2126
- streams.delete(label) if finish && streams
2127
- next if rendered.empty?
2128
-
2129
- print_block_delta(label, rendered)
2130
- finish_stream_block if finish
2131
- wrote = true
2132
- end
2133
- chunks.clear
2134
- wrote
2135
- end
2136
-
2137
- def ordered_markdown_entries(entries)
2138
- labels = entries.map(&:first)
2139
- return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
2140
-
2141
- grouped = { "Reasoning" => +"", "Assistant" => +"" }
2142
- others = []
2143
- entries.each do |label, content|
2144
- if grouped.key?(label)
2145
- grouped[label] << content.to_s
2146
- else
2147
- others << [label, content]
2148
- end
2149
- end
2150
-
2151
- [["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
2152
- end
2153
-
2154
- def message_role(message)
2155
- MessageAccess.role(message)
2156
- end
2157
-
2158
- def message_content(message)
2159
- MessageAccess.content(message)
2160
- end
2161
-
2162
- def message_summary(message)
2163
- MessageAccess.summary(message) || message_content(message)
2164
- end
2165
-
2166
- def message_name(message)
2167
- MessageAccess.name(message)
2168
- end
2169
-
2170
- def message_tool_call_id(message)
2171
- MessageAccess.tool_call_id(message)
2172
- end
2173
-
2174
- def message_tool_calls(message)
2175
- MessageAccess.tool_calls(message)
2176
- end
2177
-
2178
- def tool_call_id(tool_call)
2179
- tool_call["id"] || tool_call[:id]
2180
- end
2181
-
2182
- def export_session(conversation, argument)
2183
- path = export_path(argument)
2184
- File.write(path, markdown_transcript(conversation))
2185
- @prompt.say("\nExported session: #{path}\n")
2186
- rescue StandardError => e
2187
- @prompt.say("\nError: #{e.message}\n")
2188
- end
2189
-
2190
- def say_sessions_unavailable
2191
- @prompt.say("\nSessions are unavailable for this interactive loop.\n")
2192
- nil
2193
- end
2194
-
2195
- def clear_prompt_transcript
2196
- @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
2197
- end
2198
-
2199
- def restore_prompt_transcript(&block)
2200
- if @prompt.respond_to?(:restore_transcript)
2201
- @prompt.restore_transcript(&block)
2202
- else
2203
- block.call
2204
- end
2205
- end
2206
-
2207
- def select_session_path(session_store)
2208
- sessions = session_store.recent(limit: nil)
2209
- if sessions.empty?
2210
- @prompt.say("\nNo saved sessions found.\n")
2211
- return nil
2212
- end
2213
-
2214
- labels = sessions.map { |session| session_label(session) }
2215
- if @prompt.respond_to?(:select)
2216
- choice = @prompt.select("Session>", labels)
2217
- return nil unless choice
2218
-
2219
- selected = sessions[labels.index(choice)]
2220
- return selected&.path
2221
- end
2222
-
2223
- numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
2224
- @prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
2225
- answer = @prompt.ask("Session number or path>").to_s.strip
2226
- if answer.match?(/\A\d+\z/)
2227
- sessions[answer.to_i - 1]&.path
2228
- else
2229
- answer
2230
- end
2231
- end
2232
-
2233
- def session_label(session)
2234
- title = session.name.to_s.strip
2235
- title = session.first_message.to_s.strip if title.empty?
2236
- title = session.id if title.empty?
2237
- "#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
2238
- end
2239
-
2240
- def session_tree_prefix(session)
2241
- depth = session.respond_to?(:depth) ? session.depth.to_i : 0
2242
- return "" if depth <= 0
2243
-
2244
- ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
2245
- prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
2246
- branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
2247
- prefix + branch
2248
- end
2249
-
2250
- def export_path(argument)
2251
- default_path = if @active_session
2252
- @active_session.path.sub(/\.jsonl\z/, ".md")
2253
- else
2254
- File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
2255
- end
2256
- session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
2257
-
2258
- ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
2259
- end
2260
-
2261
- def markdown_transcript(conversation)
2262
- TranscriptExport.content(conversation)
2263
- end
2264
-
2265
- def setup_interactive_prompt
2266
- return unless @stdin.tty?
2267
- return unless @prompt.is_a?(TTY::Prompt)
2268
-
2269
- prompt_interface = load_prompt_interface
2270
- return unless prompt_interface
2271
-
2272
- banner_enabled = ConfigFiles.banner_enabled?
2273
- @prompt = prompt_interface.new(
2274
- slash_commands: slash_command_entries,
2275
- overlay_settings: ConfigFiles.overlay_settings,
2276
- footer: prompt_footer_renderer,
2277
- composer_status: method(:composer_status_text),
2278
- busy_help: ConfigFiles.composer_busy_help?,
2279
- attachment_badges: method(:composer_attachment_badges),
2280
- attachment_parser: method(:composer_attachment_parser),
2281
- banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
2282
- banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
2283
- )
2284
- @prompt.start
2285
- end
2286
-
2287
- def load_prompt_interface
2288
- require_relative "prompt_interface"
2289
- PromptInterface
2290
- rescue LoadError => e
2291
- raise unless missing_tty_tui_load_error?(e)
2292
-
2293
- nil
2294
- end
2295
-
2296
- def missing_tty_tui_load_error?(error)
2297
- ["tty-cursor", "tty-reader", "tty-screen"].include?(error.path) ||
2298
- error.message.match?(/cannot load such file -- tty-(cursor|reader|screen)/)
2299
- end
2300
-
2301
- def prompt_interface?
2302
- @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
2303
- end
2304
-
2305
- def print_visual_banner
2306
- @prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
2307
- end
2308
-
2309
- def prompt_templates
2310
- @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
2311
- end
2312
-
2313
- def plugin_registry
2314
- @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
2315
- end
2316
-
2317
- def plugin_commands
2318
- plugin_registry.commands
2319
- end
2320
-
2321
- def plugin_command_for(command)
2322
- plugin_registry.command_for(command)
2323
- end
2324
-
2325
- def reload_plugins(conversation)
2326
- @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
2327
- conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
2328
- conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
2329
- @prompt.say("\nPlugins reloaded.\n")
2330
- end
2331
-
2332
- def reserved_slash_command_names
2333
- BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
2334
- end
2335
-
2336
- def slash_command_entries
2337
- prompt_entries = prompt_templates.map do |template|
2338
- {
2339
- name: template.command,
2340
- description: template.description,
2341
- argument_hint: template.argument_hint
2342
- }
2343
- end
2344
- plugin_entries = plugin_commands.map(&:entry)
2345
- BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
2346
- end
2347
-
2348
- def prompt_template_for(command)
2349
- prompt_templates.find { |template| template.command == command }
2350
- end
2351
-
2352
- def expand_prompt_template(input)
2353
- PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
2354
- end
2355
-
2356
- def run_plugin_command(name, argument, agent)
2357
- command = plugin_command_for(name)
2358
- return [false, nil] unless command
2359
-
2360
- agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
2361
- context = plugin_context(agent.conversation, argument)
2362
- command.handler.call(argument, context)
2363
- [true, nil]
2364
- rescue StandardError => e
2365
- @prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
2366
- [true, nil]
2367
- end
2368
-
2369
- def prompt_footer_renderer
2370
- renderer = plugin_registry.footer_renderer
2371
- return nil unless renderer
2372
-
2373
- lambda do
2374
- context = plugin_context(current_footer_conversation, "")
2375
- renderer.call(context).to_s
2376
- rescue StandardError => e
2377
- warn "Warning: Kward plugin footer error: #{e.message}"
2378
- ""
2379
- end
2380
- end
2381
-
2382
- def composer_status_text
2383
- provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
2384
- model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
2385
- reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
2386
- reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
2387
- text = "#{provider} #{model} · #{reasoning}"
2388
- parts = []
2389
- diff = composer_session_diff_text
2390
- parts << diff if diff
2391
- usage = composer_context_usage(provider, model)
2392
- parts << composer_context_percent_text(usage[:percent]) if usage
2393
- parts << text
2394
- parts.join(" · ")
2395
- end
2396
-
2397
- def composer_session_diff_text
2398
- return nil if @session_diff.nil? || @session_diff.empty?
2399
-
2400
- additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
2401
- deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
2402
- "#{additions}|#{deletions}"
2403
- end
2404
-
2405
- def composer_context_percent_text(percent)
2406
- value = percent.round
2407
- color = if value >= 85
2408
- :red
2409
- elsif value >= 50
2410
- :yellow
2411
- end
2412
- ANSI.colorize("#{value}%", color, enabled: @color_enabled)
2413
- end
2414
-
2415
- def composer_context_window
2416
- provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
2417
- model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
2418
- provider = ModelInfo.provider_label(provider)
2419
- @client.respond_to?(:current_context_window) ? @client.current_context_window : ModelInfo.context_window(provider, model)
2420
- end
2421
-
2422
- def composer_context_usage(provider, model)
2423
- context_window = composer_context_window
2424
- context_parts = if @client.respond_to?(:current_context_parts)
2425
- @client.current_context_parts(current_footer_conversation.messages, footer_tool_schemas)
2426
- else
2427
- { provider: provider, model: model, messages: current_footer_conversation.messages, tools: footer_tool_schemas }
2428
- end
2429
- @context_usage.call(
2430
- provider: provider,
2431
- model: model,
2432
- context_window: context_window,
2433
- context_parts: context_parts
2434
- )
2435
- end
2436
-
2437
- def footer_tool_schemas
2438
- @footer_tool_registry&.schemas || []
2439
- end
2440
-
2441
- def current_footer_conversation
2442
- @footer_conversation || Conversation.new(system_message: nil)
2443
- end
2444
-
2445
- def plugin_context(conversation, args)
2446
- PluginRegistry::Context.new(
2447
- conversation: conversation,
2448
- args: args,
2449
- session: @active_session,
2450
- workspace_root: conversation.workspace_root,
2451
- say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
2452
- )
2453
- end
2454
-
2455
- def selected_slash_command_input(input)
2456
- return nil if prompt_interface?
2457
- return nil unless @prompt.respond_to?(:select)
2458
- return nil unless input.match?(%r{\A/[^\s/]*\z})
2459
- return nil if prompt_template_for(input.delete_prefix("/"))
2460
-
2461
- prefix = input.delete_prefix("/").downcase
2462
- return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
2463
-
2464
- matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
2465
- return nil if matches.empty?
2466
-
2467
- labels = matches.map { |entry| slash_command_label(entry) }
2468
- choice = @prompt.select("Slash command>", labels)
2469
- entry = matches[labels.index(choice)]
2470
- entry ? "/#{entry[:name]}" : nil
2471
- end
2472
-
2473
- def slash_command_label(entry)
2474
- hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
2475
- description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
2476
- "/#{entry[:name]}#{hint}#{description}"
2477
- end
2478
-
2479
- def auto_name_active_session(input)
2480
- return unless @active_session
2481
- return unless @active_session.name.to_s.strip.empty?
2482
-
2483
- name = default_session_name(input)
2484
- @active_session.rename(name) unless name.empty?
2485
- end
2486
-
2487
- def default_session_name(input)
2488
- input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
2489
- end
2490
-
2491
- def run_interactive_turn(agent, input, display_input: nil)
2492
- prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
2493
- print_user_transcript(input, display_input: display_input) if prompt_interface?
2494
- return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
2495
-
2496
- queued_inputs = []
2497
- cancellation = Cancellation.new
2498
- cancelled = false
2499
- steering = steering_supported? ? Steering.new : nil
2500
- event_queue = Queue.new
2501
- stream_state = {
2502
- streamed: false,
2503
- last_flush: monotonic_now,
2504
- stream_block_open: false,
2505
- markdown_streams: {},
2506
- defer_assistant_streaming: defer_assistant_streaming?(agent)
2507
- }
2508
- markdown_chunks = []
2509
- answer = nil
2510
- error = nil
2511
- @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
2512
-
2513
- worker = Thread.new do
2514
- options = agent_display_options(display_input)
2515
- options[:cancellation] = cancellation
2516
- options[:steering] = steering if steering
2517
- answer = agent.ask(input, **options) do |event|
2518
- event_queue << event
2519
- end
2520
- rescue StandardError => e
2521
- error = e
2522
- end
2523
- worker.report_on_exception = false
2524
-
2525
- while worker.alive?
2526
- begin
2527
- poll_result = collect_busy_input(queued_inputs, steering)
2528
- sleep 0.01
2529
- rescue Interrupt
2530
- poll_result = PromptInterface::CANCEL_INPUT
2531
- end
2532
- if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
2533
- cancelled = true
2534
- cancellation.cancel!
2535
- worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
2536
- end
2537
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
2538
- end
2539
- begin
2540
- worker.join
2541
- rescue Cancellation::CancelledError => e
2542
- error ||= e
2543
- end
2544
- drain_busy_input(queued_inputs, nil) unless cancelled
2545
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
2546
- raise error if error && !error.is_a?(Cancellation::CancelledError)
2547
-
2548
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
2549
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
2550
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
2551
- queued_inputs
2552
- ensure
2553
- @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
2554
- end
2555
-
2556
- def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
2557
- drained = 0
2558
- loop do
2559
- break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
2560
-
2561
- event = event_queue.pop(true)
2562
- drained += 1
2563
- notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
2564
- handle_interactive_event(event, markdown_chunks, stream_state)
2565
- rescue ThreadError
2566
- break
2567
- end
2568
-
2569
- flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
2570
- end
2571
-
2572
- def notify_plugin_transcript_event(event, conversation)
2573
- return unless conversation
2574
- return if plugin_registry.transcript_event_handlers.empty?
2575
-
2576
- plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
2577
- end
2578
-
2579
- def handle_interactive_event(event, markdown_chunks, stream_state)
2580
- case event
2581
- when Events::ReasoningDelta
2582
- stream_state[:streamed] = true
2583
- append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
2584
- when Events::AssistantDelta
2585
- stream_state[:streamed] = true
2586
- append_markdown_delta(markdown_chunks, "Assistant", event.delta)
2587
- when Events::SteeringApplied
2588
- @prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
2589
- when Events::Retry
2590
- stream_state[:streamed] = true
2591
- finish_interactive_markdown_deltas(markdown_chunks, stream_state)
2592
- print_retry(event)
2593
- when Events::ToolCall
2594
- stream_state[:streamed] = true
2595
- finish_interactive_markdown_deltas(markdown_chunks, stream_state)
2596
- print_tool_call(event.tool_call)
2597
- when Events::ToolResult
2598
- stream_state[:streamed] = true
2599
- finish_interactive_markdown_deltas(markdown_chunks, stream_state)
2600
- update_session_diff(event.content, tool_call: event.tool_call)
2601
- print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
2602
- end
2603
- end
2604
-
2605
- def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
2606
- if force
2607
- finish_interactive_markdown_deltas(markdown_chunks, stream_state)
2608
- return
2609
- end
2610
- return if markdown_chunks.empty?
2611
- return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
2612
-
2613
- chunks_to_flush = markdown_chunks
2614
- if stream_state[:defer_assistant_streaming]
2615
- chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
2616
- return if chunks_to_flush.empty?
2617
-
2618
- markdown_chunks.replace(delayed_chunks)
2619
- end
2620
-
2621
- stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
2622
- stream_state[:last_flush] = monotonic_now
2623
- end
2624
-
2625
- def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
2626
- wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
2627
- finish_stream_block if stream_state[:stream_block_open] && !wrote
2628
- stream_state[:stream_block_open] = false
2629
- stream_state[:last_flush] = monotonic_now
2630
- end
2631
-
2632
- def split_deferred_assistant_entries(markdown_chunks)
2633
- markdown_chunks.partition { |label, _content| label != "Assistant" }
2634
- end
2635
-
2636
- def monotonic_now
2637
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
2638
- end
2639
-
2640
- def collect_queued_input(queued_inputs)
2641
- collect_busy_input(queued_inputs, nil)
2642
- end
2643
-
2644
- def collect_busy_input(queued_inputs, steering)
2645
- return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
2646
-
2647
- poll_result = @prompt.poll_input
2648
- case poll_result
2649
- when String
2650
- if steering && !poll_result.strip.empty?
2651
- begin
2652
- steering.submit(poll_result)
2653
- @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
2654
- rescue StandardError
2655
- queued_inputs << poll_result
2656
- @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
2657
- end
2658
- else
2659
- queued_inputs << poll_result unless poll_result.strip.empty?
2660
- @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
2661
- end
2662
- when PromptInterface::EXIT_INPUT
2663
- queued_inputs << "/exit"
2664
- @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
2665
- end
2666
- poll_result
2667
- end
2668
-
2669
- def drain_queued_input(queued_inputs)
2670
- drain_busy_input(queued_inputs, nil)
2671
- end
2672
-
2673
- def drain_busy_input(queued_inputs, steering)
2674
- deadline = Time.now + 0.15
2675
- loop do
2676
- poll_result = collect_busy_input(queued_inputs, steering)
2677
- break if Time.now > deadline && poll_result.nil?
2678
-
2679
- sleep 0.01
2680
- end
2681
- end
2682
-
2683
- def steering_supported?
2684
- @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
2685
- end
2686
-
2687
- def defer_assistant_streaming?(agent)
2688
- return false unless agent.respond_to?(:conversation)
2689
-
2690
- conversation = agent.conversation
2691
- model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
2692
- ModelInfo.reasoning_supported?(current_model_provider, model)
2693
- end
2694
-
2695
- def run_blocking_interactive_turn(agent, input, display_input: nil)
2696
- streamed = false
2697
- markdown_chunks = []
2698
- answer = agent.ask(input, **agent_display_options(display_input)) do |event|
2699
- case event
2700
- when Events::ReasoningDelta
2701
- streamed = true
2702
- append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
2703
- when Events::AssistantDelta
2704
- streamed = true
2705
- append_markdown_delta(markdown_chunks, "Assistant", event.delta)
2706
- when Events::Retry
2707
- streamed = true
2708
- flush_markdown_deltas(markdown_chunks)
2709
- print_retry(event)
2710
- when Events::ToolCall
2711
- streamed = true
2712
- flush_markdown_deltas(markdown_chunks)
2713
- print_tool_call(event.tool_call)
2714
- when Events::ToolResult
2715
- streamed = true
2716
- flush_markdown_deltas(markdown_chunks)
2717
- print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
2718
- end
2719
- end
2720
- flush_markdown_deltas(markdown_chunks) if streamed
2721
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
2722
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
2723
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
2724
- []
2725
- end
2726
-
2727
- def prepare_memory_context(conversation, input)
2728
- manager = Memory::Manager.new
2729
- retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
2730
- conversation.last_memory_retrieval = retrieval
2731
- conversation.memory_context = manager.memory_block(retrieval)
2732
- conversation.refresh_system_message!
2733
- rescue StandardError => e
2734
- warn "Memory retrieval failed: #{e.message}"
2735
- nil
2736
- end
2737
-
2738
- def persist_memory_state(conversation)
2739
- @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
2740
- rescue StandardError
2741
- nil
2742
- end
2743
-
2744
- def auto_summarize_memory(conversation)
2745
- manager = Memory::Manager.new
2746
- return unless manager.enabled? && manager.auto_summary_enabled?
2747
-
2748
- summarize_memory(conversation, manager: manager)
2749
- rescue StandardError => e
2750
- warn "Memory auto-summary failed: #{e.message}"
2751
- nil
2752
- end
2753
-
2754
- def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
2755
- visible_input = display_input.nil? ? input : display_input
2756
- @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
2757
- print_attachment_badges(input, references: attachment_references)
2758
- print_pasted_images(input, image_parts: image_parts)
2759
- end
2760
-
2761
- def print_attachment_badges(input, references: nil)
2762
- badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
2763
- return if badges.empty?
2764
-
2765
- @prompt.say("#{badges.join("\n")}\n")
2766
- end
2767
-
2768
- def composer_attachment_badges(input, attachments = [])
2769
- references = Array(attachments)
2770
- references = Kward::ImageAttachments.references_from_text(input) if references.empty?
2771
- references.map { |reference| attachment_badge_text(reference) }
2772
- end
2773
-
2774
- def composer_attachment_parser(input)
2775
- Kward::ImageAttachments.extract_references_from_text(input)
2776
- end
2777
-
2778
- def submitted_display_input(input)
2779
- input.respond_to?(:display_input) ? input.display_input : nil
2780
- end
2781
-
2782
- def attachment_badge_text(reference)
2783
- status = reference[:status] || reference["status"]
2784
- label = reference[:label] || reference["label"] || "image"
2785
- if status == :missing || status.to_s == "missing"
2786
- "[image?] #{label} not found"
2787
- else
2788
- media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
2789
- size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
2790
- "[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
2791
- end
2792
- end
2793
-
2794
- def format_attachment_size(bytes)
2795
- value = bytes.to_i
2796
- return "" unless value.positive?
2797
- return "#{value} B" if value < 1024
2798
-
2799
- units = %w[KB MB GB]
2800
- size = value.to_f / 1024
2801
- unit = units.shift
2802
- while size >= 1024 && units.any?
2803
- size /= 1024
2804
- unit = units.shift
2805
- end
2806
- formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
2807
- "#{formatted} #{unit}"
2808
- end
2809
-
2810
- def agent_display_options(display_input)
2811
- display_input.nil? ? {} : { display_input: display_input }
2812
- end
2813
-
2814
- def print_pasted_images(input, image_parts: nil)
2815
- parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
2816
- parts.each do |part|
2817
- sequence = Kward::ImageAttachments.terminal_image_sequence(part)
2818
- next unless sequence
2819
-
2820
- if @prompt.respond_to?(:say_visual)
2821
- @prompt.say_visual(sequence)
2822
- else
2823
- @prompt.say(sequence)
2824
- end
2825
- end
2826
- end
2827
-
2828
- def print_block_delta(label, delta)
2829
- if prompt_interface?
2830
- @prompt.start_stream_block(label)
2831
- @prompt.write_delta(delta)
2832
- else
2833
- start_stream_block(label)
2834
- print delta
2835
- $stdout.flush
2836
- end
2837
- end
2838
-
2839
- def print_retry(event)
2840
- message = retry_message(event)
2841
- if prompt_interface?
2842
- if @prompt.respond_to?(:write_stream_block)
2843
- @prompt.write_stream_block("Retry", "#{message}\n", finish: true)
2844
- else
2845
- @prompt.start_stream_block("Retry")
2846
- @prompt.write_delta("#{message}\n")
2847
- @prompt.finish_stream_block
2848
- end
2849
- else
2850
- start_stream_block("Retry")
2851
- puts message
2852
- $stdout.flush
2853
- @stream_block = nil
2854
- end
2855
- end
2856
-
2857
- def retry_message(event)
2858
- RetryMessage.format(event)
2859
- end
2860
-
2861
- def print_tool_call(tool_call)
2862
- if prompt_interface?
2863
- if @prompt.respond_to?(:write_stream_block)
2864
- @prompt.write_stream_block("Tool", "#{tool_command(tool_call)}\n", finish: true)
2865
- else
2866
- @prompt.start_stream_block("Tool")
2867
- @prompt.write_delta("#{tool_command(tool_call)}\n")
2868
- @prompt.finish_stream_block
2869
- end
2870
- else
2871
- start_stream_block("Tool")
2872
- puts tool_command(tool_call)
2873
- $stdout.flush
2874
- @stream_block = nil
2875
- end
2876
- end
2877
-
2878
- def print_tool_result(tool_call, content, line_limit: nil)
2879
- summary = tool_result_summary(tool_call, content)
2880
- summary = limit_tool_output_lines(summary, line_limit) if line_limit
2881
- if prompt_interface?
2882
- summary = summary.end_with?("\n") ? summary : "#{summary}\n"
2883
- if @prompt.respond_to?(:write_stream_block)
2884
- @prompt.write_stream_block("Tool output", summary, finish: true)
2885
- else
2886
- @prompt.start_stream_block("Tool output")
2887
- @prompt.write_delta(summary)
2888
- @prompt.finish_stream_block
2889
- end
2890
- else
2891
- start_stream_block("Tool output")
2892
- print summary
2893
- puts unless summary.end_with?("\n")
2894
- $stdout.flush
2895
- @stream_block = nil
2896
- end
2897
- end
2898
-
2899
- def tool_result_summary(tool_call, content)
2900
- name = tool_call_name(tool_call)
2901
- args = tool_call_args(tool_call)
2902
- text = content.to_s
2903
- return error_tool_summary(name, args, text) if text.start_with?("Error:", "Declined:")
2904
-
2905
- case name
2906
- when "read_file"
2907
- read_file_summary(args, text)
2908
- when "write_file", "edit_file"
2909
- file_change_summary(name, args, text)
2910
- when "run_shell_command"
2911
- shell_command_summary(args, text)
2912
- when "web_search"
2913
- web_search_summary(args, text)
2914
- else
2915
- generic_tool_summary(name, text)
2916
- end
2917
- end
2918
-
2919
- def limit_tool_output_lines(content, line_limit)
2920
- lines = content.to_s.lines
2921
- return content.to_s if lines.length <= line_limit
2922
-
2923
- kept_lines = lines.first(line_limit - 1).join
2924
- omitted_lines = lines.length - (line_limit - 1)
2925
- suffix = omitted_lines == 1 ? "line" : "lines"
2926
- notice = "...[truncated #{omitted_lines} #{suffix}]"
2927
- kept_lines.end_with?("\n") || kept_lines.empty? ? "#{kept_lines}#{notice}" : "#{kept_lines}\n#{notice}"
2928
- end
2929
-
2930
- def read_file_summary(args, content)
2931
- path = args["path"] || args[:path] || "(unknown path)"
2932
- "read_file: #{path}\n#{content.lines.count} lines, #{content.bytesize} bytes"
2933
- end
2934
-
2935
- def file_change_summary(name, args, content)
2936
- path = args["path"] || args[:path] || path_from_tool_result(content) || "(unknown path)"
2937
- concise = content.lines.first.to_s.strip
2938
- concise = "completed" if concise.empty?
2939
- "#{name}: #{path}\n#{concise}"
2940
- end
2941
-
2942
- def shell_command_summary(args, content)
2943
- command = args["command"] || args[:command] || ""
2944
- lines = ["run_shell_command: #{command}".strip]
2945
- lines << "Exit status: #{shell_exit_status(content) || "unknown"}"
2946
- stdout = shell_section(content, "STDOUT")
2947
- stderr = shell_section(content, "STDERR")
2948
- lines << compact_stream_summary("stdout", stdout) unless stdout.empty?
2949
- lines << compact_stream_summary("stderr", stderr) unless stderr.empty?
2950
- lines.join("\n")
2951
- end
2952
-
2953
- def web_search_summary(args, content)
2954
- queries = Array(args["queries"] || args[:queries]).map(&:to_s)
2955
- queries = web_search_queries_from_content(content) if queries.empty?
2956
- counts = web_search_result_counts(content)
2957
- lines = ["web_search"]
2958
- queries.each do |query|
2959
- lines << "#{query}: #{counts.fetch(query, 0)} result(s)"
2960
- end
2961
- lines << "#{web_search_total_count(content)} result(s)" if queries.empty?
2962
- lines.join("\n")
2963
- end
2964
-
2965
- def error_tool_summary(name, args, content)
2966
- path = args["path"] || args[:path]
2967
- command = args["command"] || args[:command]
2968
- context = path || command
2969
- [name, context, content.lines.first.to_s.strip].compact.reject(&:empty?).join("\n")
2970
- end
2971
-
2972
- def generic_tool_summary(name, content)
2973
- text = content.to_s
2974
- return "#{name}: #{text}" if text.length <= RESTORED_TOOL_OUTPUT_LIMIT
2975
-
2976
- "#{name}: #{text[0, RESTORED_TOOL_OUTPUT_LIMIT]}\n...[truncated #{text.length - RESTORED_TOOL_OUTPUT_LIMIT} bytes]"
2977
- end
2978
-
2979
- def compact_stream_summary(label, text)
2980
- summary = text.strip
2981
- summary = summary[0, 500] + "\n...[truncated #{summary.length - 500} chars]" if summary.length > 500
2982
- "#{label} (#{text.bytesize} bytes):#{summary.empty? ? "" : "\n#{summary}"}"
2983
- end
2984
-
2985
- def shell_exit_status(content)
2986
- content.match(/^Exit status: ([^\n]+)/)&.[](1)
2987
- end
2988
-
2989
- def shell_section(content, name)
2990
- match = content.match(/^#{Regexp.escape(name)}:\n(.*?)(?=\nSTD(?:OUT|ERR):\n|\z)/m)
2991
- match ? match[1] : ""
2992
- end
2993
-
2994
- def web_search_queries_from_content(content)
2995
- content.scan(/^## Query: (.+)$/).flatten
2996
- end
2997
-
2998
- def web_search_result_counts(content)
2999
- counts = {}
3000
- current_query = nil
3001
- content.each_line do |line|
3002
- if (match = line.match(/^## Query: (.+)$/))
3003
- current_query = match[1]
3004
- counts[current_query] ||= 0
3005
- elsif current_query && line.match?(/^\d+\. /)
3006
- counts[current_query] += 1
3007
- end
3008
- end
3009
- counts
3010
- end
3011
-
3012
- def web_search_total_count(content)
3013
- content.each_line.count { |line| line.match?(/^\d+\. /) }
3014
- end
3015
-
3016
- def path_from_tool_result(content)
3017
- content.match(/\b(?:to|file|Edited)\s+([^:\n]+?)(?:\s|:|\z)/)&.[](1)
3018
- end
3019
-
3020
- def tool_call_name(tool_call)
3021
- ToolCall.name(tool_call) || "unknown_tool"
3022
- end
3023
-
3024
- def tool_call_args(tool_call)
3025
- ToolCall.arguments(tool_call)
3026
- end
3027
-
3028
- def start_stream_block(label)
3029
- return if @stream_block == label
3030
-
3031
- puts if @stream_block
3032
- puts "\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}"
3033
- @stream_block = label
3034
- end
3035
-
3036
- def finish_stream_block
3037
- if prompt_interface?
3038
- @prompt.finish_stream_block
3039
- else
3040
- puts if @stream_block
3041
- @stream_block = nil
3042
- end
3043
- end
3044
-
3045
- def colored(text, *styles)
3046
- ANSI.colorize(text, *styles, enabled: @color_enabled)
3047
- end
3048
-
3049
- def transcript_label(label)
3050
- label == "Assistant" ? assistant_prompt_name : label
3051
- end
3052
-
3053
- def label_color(label)
3054
- case label
3055
- when "Reasoning"
3056
- :yellow
3057
- when "Assistant", "Kward"
3058
- :green
3059
- when "Tool"
3060
- :magenta
3061
- when "Tool output"
3062
- :cyan
3063
- else
3064
- :blue
3065
- end
3066
- end
3067
-
3068
- def tool_command(tool_call)
3069
- name = tool_call_name(tool_call)
3070
- args = tool_call_args(tool_call)
3071
-
3072
- if name == "run_shell_command"
3073
- args["command"] || args[:command] || ""
3074
- elsif args.empty?
3075
- name.to_s
3076
- else
3077
- "#{name} #{JSON.dump(args)}"
3078
- end
3079
- end
3080
-
3081
329
  end
3082
330
  end