kward 0.66.0 → 0.67.1

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.
data/lib/kward/cli.rb CHANGED
@@ -1,13 +1,15 @@
1
- require "base64"
1
+ require "fileutils"
2
2
  require "json"
3
3
  require "thread"
4
4
  require "tty-prompt"
5
5
  require_relative "agent"
6
6
  require_relative "ansi"
7
+ require_relative "version"
7
8
  require_relative "model/client"
8
9
  require_relative "compactor"
9
10
  require_relative "config_files"
10
11
  require_relative "clipboard"
12
+ require_relative "cli_transcript_formatter"
11
13
  require_relative "model/context_usage"
12
14
  require_relative "events"
13
15
  require_relative "export_path"
@@ -26,6 +28,7 @@ require_relative "model/retry_message"
26
28
  require_relative "rpc/server"
27
29
  require_relative "session_diff"
28
30
  require_relative "session_store"
31
+ require_relative "session_tree_renderer"
29
32
  require_relative "starter_pack_installer"
30
33
  require_relative "steering"
31
34
  require_relative "tools/tool_call"
@@ -57,6 +60,8 @@ module Kward
57
60
  @session_diff = SessionDiff.new
58
61
  @cleanup_sessions = []
59
62
  @plugin_registry = nil
63
+ @working_directory = nil
64
+ @prompt_delimited = false
60
65
  @color_enabled = ANSI.enabled?($stdout)
61
66
  end
62
67
 
@@ -65,35 +70,117 @@ module Kward
65
70
  #
66
71
  # @return [void]
67
72
  def run
73
+ @argv = extract_global_options(@argv)
74
+ with_working_directory { dispatch }
75
+ rescue ArgumentError => e
76
+ warn e.message
77
+ warn "Run `kward help` for available commands."
78
+ exit 1
79
+ end
80
+
81
+ def dispatch
82
+ if @prompt_delimited
83
+ ConfigFiles.ensure_default_config!
84
+ run_prompt_or_interactive
85
+ return
86
+ end
87
+
88
+ if help_command?
89
+ print_command_help(@argv[1])
90
+ return
91
+ end
92
+ raise ArgumentError, command_usage("help") if ["help", "--help", "-h"].include?(@argv.first)
93
+
94
+ if version_command?
95
+ print_version
96
+ return
97
+ end
98
+ raise ArgumentError, command_usage("version") if ["version", "--version", "-v"].include?(@argv.first)
99
+
68
100
  ConfigFiles.ensure_default_config!
69
101
 
102
+ if @argv.first == "init"
103
+ if help_option_arguments?(@argv[1..] || [])
104
+ print_command_help("init")
105
+ return
106
+ end
107
+ raise ArgumentError, command_usage("init") unless @argv.length == 1
108
+
109
+ install_starter_pack
110
+ return
111
+ end
112
+
70
113
  if @argv == ["--install-starter-pack"]
71
114
  install_starter_pack
72
115
  return
73
116
  end
74
117
 
75
- if @argv.first == "rpc" && @argv.length == 1
118
+ if @argv.first == "auth"
119
+ handle_auth_command(@argv[1..] || [])
120
+ return
121
+ end
122
+
123
+ if @argv.first == "doctor"
124
+ if help_option_arguments?(@argv[1..] || [])
125
+ print_command_help("doctor")
126
+ return
127
+ end
128
+ raise ArgumentError, command_usage("doctor") unless @argv.length == 1
129
+
130
+ print_doctor
131
+ return
132
+ end
133
+
134
+ if @argv.first == "rpc"
135
+ if help_option_arguments?(@argv[1..] || [])
136
+ print_command_help("rpc")
137
+ return
138
+ end
139
+ raise ArgumentError, command_usage("rpc") unless @argv.length == 1
140
+
76
141
  Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
77
142
  return
78
143
  end
79
144
 
80
- if @argv[0, 2] == ["stats", "tokens"]
145
+ if @argv.first == "stats"
146
+ if @argv[1] == "tokens" && help_option_arguments?(@argv[2..] || [])
147
+ print_command_help("stats")
148
+ return
149
+ end
150
+ raise ArgumentError, command_usage("stats") unless @argv[1] == "tokens"
151
+
81
152
  export_token_stats(@argv[2..] || [])
82
153
  return
83
154
  end
84
155
 
85
156
  if pan_mode?
86
- PanServer.new(client: @client, working_directory: pan_working_directory).run
157
+ if help_option_arguments?(@argv[1..] || [])
158
+ print_command_help("pan")
159
+ return
160
+ end
161
+ raise ArgumentError, command_usage("pan") unless @argv.length == 1
162
+
163
+ PanServer.new(client: @client, working_directory: current_workspace_root).run
87
164
  return
88
165
  end
89
166
 
90
- if ["login", "--login"].include?(@argv.first) && @argv.length <= 2
167
+ if ["login", "--login"].include?(@argv.first)
168
+ if help_option_arguments?(@argv[1..] || [])
169
+ print_command_help("login")
170
+ return
171
+ end
172
+ raise ArgumentError, command_usage("login") unless @argv.length <= 2
173
+
91
174
  login(provider: @argv[1])
92
175
  return
93
176
  end
94
177
 
95
- first_prompt = @argv.join(" ").strip
96
- unless first_prompt.empty?
178
+ run_prompt_or_interactive
179
+ end
180
+
181
+ def run_prompt_or_interactive
182
+ first_prompt = one_shot_prompt_argument
183
+ if first_prompt
97
184
  answer = one_shot(first_prompt)
98
185
  puts answer unless answer.empty?
99
186
  return
@@ -116,7 +203,7 @@ module Kward
116
203
  conversation = new_conversation
117
204
  agent = Agent.new(
118
205
  client: @client,
119
- tool_registry: ToolRegistry.new(prompt: @prompt),
206
+ tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
120
207
  conversation: conversation
121
208
  )
122
209
  answer = agent.ask(input) do |event|
@@ -146,6 +233,54 @@ module Kward
146
233
  assistant_streamed ? "" : render_markdown_transcript(answer)
147
234
  end
148
235
 
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
+
272
+ File.delete(path)
273
+ removed << path
274
+ end
275
+ removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
276
+
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
+
149
284
  def login(provider: nil, oauth: nil)
150
285
  provider = provider.to_s.downcase
151
286
  if provider == "openrouter"
@@ -164,12 +299,9 @@ module Kward
164
299
  def interactive_loop(agent: nil)
165
300
  setup_interactive_prompt
166
301
  session_store = interactive_session_store(agent)
302
+ @resumed_last_session = false
167
303
  if session_store && agent.nil?
168
- @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
169
- reset_session_diff
170
- conversation = new_conversation(workspace_root: session_store.cwd)
171
- @active_session.attach(conversation)
172
- agent = build_interactive_agent(conversation)
304
+ agent = resume_last_session(session_store) || build_new_session_agent(session_store)
173
305
  elsif session_store
174
306
  @active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
175
307
  reset_session_diff
@@ -181,7 +313,8 @@ module Kward
181
313
  update_assistant_prompt(agent.conversation)
182
314
  @footer_conversation = agent.conversation
183
315
 
184
- print_visual_banner
316
+ print_visual_banner unless @resumed_last_session
317
+ render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
185
318
 
186
319
  @pending_inputs = []
187
320
 
@@ -207,12 +340,14 @@ module Kward
207
340
  agent = replacement_agent if replacement_agent
208
341
  end
209
342
  next if handled
343
+ next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
210
344
 
211
345
  expanded_input = expand_prompt_template(input)
212
346
  display_input = display_input || input if expanded_input
213
347
  input = expanded_input || input
214
348
  @footer_conversation = agent.conversation
215
349
  begin
350
+ auto_name_active_session(display_input || input)
216
351
  pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
217
352
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
218
353
  rescue StandardError => e
@@ -229,6 +364,7 @@ module Kward
229
364
  @prompt.close if prompt_interface?
230
365
  ensure
231
366
  cleanup_unused_sessions
367
+ remember_active_session(session_store)
232
368
  end
233
369
  end
234
370
 
@@ -240,6 +376,266 @@ module Kward
240
376
 
241
377
  private
242
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
+
243
639
  def install_starter_pack
244
640
  result = StarterPackInstaller.install
245
641
  installed_count = result.installed.length
@@ -252,7 +648,7 @@ module Kward
252
648
  end
253
649
 
254
650
  def pan_mode?
255
- @argv.include?("--pan-mode")
651
+ ["pan", "--pan-mode"].include?(@argv.first)
256
652
  end
257
653
 
258
654
  def export_token_stats(arguments)
@@ -299,17 +695,46 @@ module Kward
299
695
  { range: remaining.join(" "), bucket: bucket, output: output }
300
696
  end
301
697
 
302
- def pan_working_directory
303
- value = option_value("--working-directory")
304
- value.to_s.strip.empty? ? Dir.pwd : value
305
- end
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
306
711
 
307
- def option_value(name)
308
- @argv.each_with_index do |argument, index|
309
- return argument.split("=", 2).last if argument.start_with?("#{name}=")
310
- return @argv[index + 1] if argument == name
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
311
719
  end
312
- nil
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 }
313
738
  end
314
739
 
315
740
  def interactive_session_store(agent)
@@ -319,6 +744,43 @@ module Kward
319
744
  SessionStore.new
320
745
  end
321
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
+
322
784
  def track_session(session)
323
785
  @cleanup_sessions << session if session
324
786
  session
@@ -328,12 +790,17 @@ module Kward
328
790
  @session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
329
791
  end
330
792
 
331
- def update_session_diff(content)
793
+ def update_session_diff(content, tool_call: nil)
794
+ return unless mutation_tool_call?(tool_call)
332
795
  return unless @session_diff&.add_tool_result(content)
333
796
 
334
797
  @prompt.redraw if @prompt.respond_to?(:redraw)
335
798
  end
336
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
+
337
804
  def cleanup_unused_sessions
338
805
  @cleanup_sessions.reverse_each do |session|
339
806
  session.delete_if_unused if session.respond_to?(:delete_if_unused)
@@ -348,7 +815,7 @@ module Kward
348
815
  previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
349
816
  end
350
817
 
351
- def new_conversation(workspace_root: Dir.pwd)
818
+ def new_conversation(workspace_root: current_workspace_root)
352
819
  Conversation.new(workspace_root: workspace_root, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
353
820
  end
354
821
 
@@ -375,7 +842,7 @@ module Kward
375
842
 
376
843
  def build_interactive_agent(conversation)
377
844
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
378
- workspace = Workspace.new(root: conversation.workspace_root)
845
+ workspace = configured_workspace(root: conversation.workspace_root)
379
846
  tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
380
847
  @footer_conversation = conversation
381
848
  @footer_tool_registry = tool_registry
@@ -386,6 +853,39 @@ module Kward
386
853
  )
387
854
  end
388
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
+
389
889
  def handle_local_slash_command(command, agent, session_store)
390
890
  name, argument = parse_slash_command(command)
391
891
  case name
@@ -395,9 +895,6 @@ module Kward
395
895
  when "stats"
396
896
  run_busy_local_command_and_requeue { print_stats(argument) }
397
897
  [true, nil]
398
- when "crew"
399
- @prompt.say("\nThe /crew command is not implemented yet.\n")
400
- [true, nil]
401
898
  when "memory"
402
899
  activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
403
900
  run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
@@ -406,7 +903,7 @@ module Kward
406
903
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
407
904
  [true, nil]
408
905
  when "settings"
409
- configure_settings
906
+ configure_settings(agent.conversation)
410
907
  [true, nil]
411
908
  when "login"
412
909
  login_interactively
@@ -421,6 +918,9 @@ module Kward
421
918
  when "reasoning"
422
919
  configure_reasoning(agent.conversation)
423
920
  [true, nil]
921
+ when "reload"
922
+ run_busy_local_command_and_requeue { reload_plugins(agent.conversation) }
923
+ [true, nil]
424
924
  when "new"
425
925
  [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
426
926
  when "resume"
@@ -434,6 +934,8 @@ module Kward
434
934
  [true, nil]
435
935
  when "clone"
436
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) }]
437
939
  when "copy"
438
940
  run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
439
941
  [true, nil]
@@ -528,13 +1030,16 @@ module Kward
528
1030
  record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
529
1031
  @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
530
1032
  when "list"
531
- @prompt.say("\n#{format_memory_list(manager.list)}\n")
1033
+ @prompt.say("\n#{format_memory_list(manager.hierarchy(workspace_root: agent.conversation.workspace_root))}\n")
532
1034
  when "forget"
533
1035
  forgotten = manager.forget_memory(rest.to_s.strip)
534
1036
  @prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
535
1037
  when "promote"
536
- record = manager.promote_soft_to_core(rest.to_s.strip)
537
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted to core memory #{record["id"]}.\n")
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")
538
1043
  when "inspect"
539
1044
  @prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
540
1045
  when "why"
@@ -544,7 +1049,7 @@ module Kward
544
1049
  records = summarize_memory(agent.conversation, manager: manager)
545
1050
  @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
546
1051
  else
547
- @prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|inspect|why|summarize\n")
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")
548
1053
  end
549
1054
  rescue StandardError => e
550
1055
  @prompt.say("\nMemory command failed: #{e.message}\n")
@@ -563,13 +1068,18 @@ module Kward
563
1068
  end
564
1069
 
565
1070
  def format_memory_list(memories)
566
- lines = ["Core Memories:"]
567
- Array(memories["core"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
568
- lines << "- none" if Array(memories["core"]).empty?
569
- lines << "Soft Memories:"
570
- Array(memories["soft"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
571
- lines << "- none" if Array(memories["soft"]).empty?
572
- lines.join("\n")
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")
573
1083
  end
574
1084
 
575
1085
  def format_memory_why(explanation)
@@ -616,33 +1126,478 @@ module Kward
616
1126
 
617
1127
  def current_workspace_root
618
1128
  return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
1129
+ return @working_directory if @working_directory
619
1130
 
620
1131
  Dir.pwd
621
1132
  end
622
1133
 
623
- def configure_settings
1134
+ def configure_settings(conversation = nil)
624
1135
  unless settings_overlay_available?
625
1136
  @prompt.say("\nSettings overlay is unavailable in this prompt.\n")
626
1137
  return
627
1138
  end
628
1139
 
629
- settings = ConfigFiles.overlay_settings
630
- alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
631
- return unless alignment
632
-
633
- settings = ConfigFiles.update_overlay_settings("alignment" => alignment)
634
- @prompt.update_overlay_settings(settings)
1140
+ loop do
1141
+ selected = @prompt.select("Settings category", settings_category_choices, title: "Settings")
1142
+ category = selected_settings_category(selected)
1143
+ break unless category
635
1144
 
636
- width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
637
- return unless width
1145
+ break if category == "done"
638
1146
 
639
- settings = ConfigFiles.update_overlay_settings("width" => width)
640
- @prompt.update_overlay_settings(settings)
641
- @prompt.say("\nSaved overlay settings.\n")
1147
+ handle_settings_category(category, conversation)
1148
+ end
642
1149
  rescue StandardError => e
643
1150
  @prompt.say("\nSettings error: #{e.message}\n")
644
1151
  end
645
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
+
646
1601
  def login_interactively
647
1602
  unless login_picker_available?
648
1603
  @prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
@@ -862,7 +1817,7 @@ module Kward
862
1817
  return nil if path.to_s.empty?
863
1818
 
864
1819
  previous_session = @active_session
865
- @active_session, conversation = session_store.load(path, workspace: Workspace.new(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
1820
+ @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
866
1821
  reset_session_diff(@active_session.path)
867
1822
  track_session(@active_session)
868
1823
  cleanup_replaced_session(previous_session)
@@ -879,6 +1834,92 @@ module Kward
879
1834
  nil
880
1835
  end
881
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
+
882
1923
  def rename_session(argument)
883
1924
  unless @active_session
884
1925
  @prompt.say("\nNo active persisted session.\n")
@@ -931,6 +1972,10 @@ module Kward
931
1972
  nil
932
1973
  end
933
1974
 
1975
+ def full_message_text(message)
1976
+ CLITranscriptFormatter.full_text(message)
1977
+ end
1978
+
934
1979
  def copy_target_content(conversation, target)
935
1980
  case target
936
1981
  when "last"
@@ -946,7 +1991,7 @@ module Kward
946
1991
  message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
947
1992
  return "" unless message
948
1993
 
949
- message_content_text(message_content(message))
1994
+ CLITranscriptFormatter.content_text(message_content(message))
950
1995
  end
951
1996
 
952
1997
  def copy_target_label(target)
@@ -978,10 +2023,10 @@ module Kward
978
2023
  case role
979
2024
  when "user"
980
2025
  print_user_transcript(
981
- message_user_transcript_input(message),
982
- display_input: message_user_display_text(message),
983
- attachment_references: message_image_references(message),
984
- image_parts: message_image_parts(message)
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)
985
2030
  )
986
2031
  when "assistant"
987
2032
  render_reasoning(message)
@@ -995,25 +2040,25 @@ module Kward
995
2040
  when "compactionSummary"
996
2041
  render_transcript_block("Compaction summary", message_summary(message))
997
2042
  else
998
- render_transcript_block(role.to_s.capitalize, message_content_text(message_content(message)))
2043
+ render_transcript_block(role.to_s.capitalize, CLITranscriptFormatter.content_text(message_content(message)))
999
2044
  end
1000
2045
  end
1001
2046
  end
1002
2047
 
1003
2048
  def render_reasoning(message)
1004
- reasoning = message_reasoning(message)
2049
+ reasoning = CLITranscriptFormatter.reasoning(message)
1005
2050
  render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
1006
2051
  end
1007
2052
 
1008
2053
  def render_assistant_message(message)
1009
- content = message_content_text(message_content(message))
2054
+ content = CLITranscriptFormatter.content_text(message_content(message))
1010
2055
  return if content.empty?
1011
2056
 
1012
2057
  render_transcript_block("Assistant", content)
1013
2058
  end
1014
2059
 
1015
2060
  def render_tool_message(message, tool_calls_by_id)
1016
- tool_call = tool_calls_by_id[message_tool_call_id(message)] || synthetic_tool_call(message_name(message), message_tool_call_id(message))
2061
+ tool_call = tool_calls_by_id[message_tool_call_id(message)] || CLITranscriptFormatter.synthetic_tool_call(message_name(message), message_tool_call_id(message))
1017
2062
  render_tool_result(tool_call, message_content(message).to_s)
1018
2063
  end
1019
2064
 
@@ -1106,113 +2151,6 @@ module Kward
1106
2151
  [["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
1107
2152
  end
1108
2153
 
1109
- def message_reasoning(message)
1110
- direct = message["reasoning_summary"] || message[:reasoning_summary]
1111
- return direct.to_s unless direct.to_s.empty?
1112
-
1113
- content = message_content(message)
1114
- return "" unless content.is_a?(Array)
1115
-
1116
- content.filter_map do |part|
1117
- type = part["type"] || part[:type]
1118
- next unless ["thinking", "reasoning"].include?(type)
1119
-
1120
- part["thinking"] || part[:thinking] || part["text"] || part[:text]
1121
- end.join("\n")
1122
- end
1123
-
1124
- def message_content_text(content)
1125
- case content
1126
- when Array
1127
- content.filter_map do |part|
1128
- type = part["type"] || part[:type]
1129
- if type == "text"
1130
- part["text"] || part[:text]
1131
- elsif type == "image"
1132
- path = part["path"] || part[:path]
1133
- media_type = part["media_type"] || part[:media_type] || "image"
1134
- "[#{media_type}#{path ? ": #{path}" : ""}]"
1135
- end
1136
- end.join("\n")
1137
- else
1138
- content.to_s
1139
- end
1140
- end
1141
-
1142
- def message_display_text(message)
1143
- display_content = MessageAccess.display_content(message)
1144
- return display_content.to_s unless display_content.nil?
1145
-
1146
- message_content_text(message_content(message))
1147
- end
1148
-
1149
- def message_user_display_text(message)
1150
- display_content = MessageAccess.display_content(message)
1151
- return display_content.to_s unless display_content.nil?
1152
-
1153
- content = message_content(message)
1154
- return content.to_s unless content.is_a?(Array)
1155
-
1156
- text = content.filter_map do |part|
1157
- type = part["type"] || part[:type]
1158
- next unless type == "text"
1159
-
1160
- part["text"] || part[:text]
1161
- end.join("\n")
1162
- Kward::ImageAttachments.display_text_without_references(text, Kward::ImageAttachments.references_from_text(text).select { |reference| reference[:status] == :attached })
1163
- end
1164
-
1165
- def message_user_transcript_input(message)
1166
- content = message_content(message)
1167
- return content.to_s unless content.is_a?(Array)
1168
-
1169
- message_user_display_text(message)
1170
- end
1171
-
1172
- def message_image_parts(message)
1173
- content = message_content(message)
1174
- return [] unless content.is_a?(Array)
1175
-
1176
- content.select do |part|
1177
- type = part["type"] || part[:type]
1178
- type == "image"
1179
- end
1180
- end
1181
-
1182
- def message_image_references(message)
1183
- message_image_parts(message).map { |part| image_part_reference(part) }
1184
- end
1185
-
1186
- def image_part_reference(part)
1187
- data = part[:data] || part["data"]
1188
- path = part[:path] || part["path"]
1189
- media_type = part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"] || "image"
1190
- {
1191
- status: :attached,
1192
- type: "image",
1193
- label: path.to_s.empty? ? "pasted image" : File.basename(path),
1194
- media_type: media_type,
1195
- size_bytes: decoded_image_size(data),
1196
- path: path
1197
- }
1198
- end
1199
-
1200
- def decoded_image_size(data)
1201
- return nil if data.to_s.empty?
1202
-
1203
- Base64.decode64(data.to_s.gsub(/\s+/, "")).bytesize
1204
- rescue ArgumentError
1205
- nil
1206
- end
1207
-
1208
- def synthetic_tool_call(name, id)
1209
- {
1210
- "id" => id || "restored_tool",
1211
- "type" => "function",
1212
- "function" => { "name" => name || "tool", "arguments" => "{}" }
1213
- }
1214
- end
1215
-
1216
2154
  def message_role(message)
1217
2155
  MessageAccess.role(message)
1218
2156
  end
@@ -1267,10 +2205,7 @@ module Kward
1267
2205
  end
1268
2206
 
1269
2207
  def select_session_path(session_store)
1270
- recent_limit = 20
1271
- sessions = session_store.recent_tree(limit: recent_limit + 1)
1272
- .reject { |session| active_empty_unnamed_session_info?(session) }
1273
- .first(recent_limit)
2208
+ sessions = session_store.recent(limit: nil)
1274
2209
  if sessions.empty?
1275
2210
  @prompt.say("\nNo saved sessions found.\n")
1276
2211
  return nil
@@ -1295,13 +2230,6 @@ module Kward
1295
2230
  end
1296
2231
  end
1297
2232
 
1298
- def active_empty_unnamed_session_info?(session)
1299
- return false unless @active_session
1300
- return false unless File.expand_path(session.path) == File.expand_path(@active_session.path)
1301
-
1302
- session.name.to_s.strip.empty? && session.message_count.to_i.zero?
1303
- end
1304
-
1305
2233
  def session_label(session)
1306
2234
  title = session.name.to_s.strip
1307
2235
  title = session.first_message.to_s.strip if title.empty?
@@ -1341,6 +2269,7 @@ module Kward
1341
2269
  prompt_interface = load_prompt_interface
1342
2270
  return unless prompt_interface
1343
2271
 
2272
+ banner_enabled = ConfigFiles.banner_enabled?
1344
2273
  @prompt = prompt_interface.new(
1345
2274
  slash_commands: slash_command_entries,
1346
2275
  overlay_settings: ConfigFiles.overlay_settings,
@@ -1349,8 +2278,8 @@ module Kward
1349
2278
  busy_help: ConfigFiles.composer_busy_help?,
1350
2279
  attachment_badges: method(:composer_attachment_badges),
1351
2280
  attachment_parser: method(:composer_attachment_parser),
1352
- banner_pixels: Kward::PromptInterface::BANNER_LOGO_PIXELS,
1353
- banner_message: Kward::PromptInterface::BANNER_MESSAGE
2281
+ banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
2282
+ banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
1354
2283
  )
1355
2284
  @prompt.start
1356
2285
  end
@@ -1393,6 +2322,13 @@ module Kward
1393
2322
  plugin_registry.command_for(command)
1394
2323
  end
1395
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
+
1396
2332
  def reserved_slash_command_names
1397
2333
  BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
1398
2334
  end
@@ -1540,6 +2476,18 @@ module Kward
1540
2476
  "/#{entry[:name]}#{hint}#{description}"
1541
2477
  end
1542
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
+
1543
2491
  def run_interactive_turn(agent, input, display_input: nil)
1544
2492
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
1545
2493
  print_user_transcript(input, display_input: display_input) if prompt_interface?
@@ -1649,7 +2597,7 @@ module Kward
1649
2597
  when Events::ToolResult
1650
2598
  stream_state[:streamed] = true
1651
2599
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
1652
- update_session_diff(event.content)
2600
+ update_session_diff(event.content, tool_call: event.tool_call)
1653
2601
  print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
1654
2602
  end
1655
2603
  end
@@ -1891,9 +2839,13 @@ module Kward
1891
2839
  def print_retry(event)
1892
2840
  message = retry_message(event)
1893
2841
  if prompt_interface?
1894
- @prompt.start_stream_block("Retry")
1895
- @prompt.write_delta("#{message}\n")
1896
- @prompt.finish_stream_block
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
1897
2849
  else
1898
2850
  start_stream_block("Retry")
1899
2851
  puts message
@@ -1908,9 +2860,13 @@ module Kward
1908
2860
 
1909
2861
  def print_tool_call(tool_call)
1910
2862
  if prompt_interface?
1911
- @prompt.start_stream_block("Tool")
1912
- @prompt.write_delta("#{tool_command(tool_call)}\n")
1913
- @prompt.finish_stream_block
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
1914
2870
  else
1915
2871
  start_stream_block("Tool")
1916
2872
  puts tool_command(tool_call)
@@ -1923,10 +2879,14 @@ module Kward
1923
2879
  summary = tool_result_summary(tool_call, content)
1924
2880
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
1925
2881
  if prompt_interface?
1926
- @prompt.start_stream_block("Tool output")
1927
- @prompt.write_delta(summary)
1928
- @prompt.write_delta("\n") unless summary.end_with?("\n")
1929
- @prompt.finish_stream_block
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
1930
2890
  else
1931
2891
  start_stream_block("Tool output")
1932
2892
  print summary