kward 0.70.0 → 0.71.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -53,6 +53,7 @@ module Kward
53
53
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
54
54
  #{command.call("kward doctor")} Check local Kward setup
55
55
  #{command.call("kward sysprompt")} Inspect the effective system prompt
56
+ #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
56
57
  #{command.call("kward pan")} Start Pan mode web UI
57
58
  #{command.call("kward rpc")} Start the experimental JSON-RPC backend
58
59
 
@@ -65,6 +66,7 @@ module Kward
65
66
  #{command.call("doctor")} Check local Kward setup
66
67
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
67
68
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
69
+ #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
68
70
  #{command.call("pan")} Start Pan mode web UI
69
71
  #{command.call("rpc")} Run the JSON-RPC backend for UI clients
70
72
 
@@ -78,6 +80,7 @@ module Kward
78
80
  #{command.call("kward")} #{option.call('"Review this diff"')}
79
81
  #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
80
82
  #{command.call("kward login openrouter")}
83
+ #{command.call("kward openrouter refresh")}
81
84
  #{command.call("kward stats tokens today --bucket hour")}
82
85
 
83
86
  Command names take precedence. Anything else is sent as a one-shot prompt.
@@ -126,6 +129,11 @@ module Kward
126
129
  description: "Export local token telemetry as CSV.",
127
130
  examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
128
131
  },
132
+ "openrouter" => {
133
+ usage: "kward openrouter refresh|list",
134
+ description: "Refresh or list cached text-capable OpenRouter models available to your API key.",
135
+ examples: ["kward openrouter refresh", "kward openrouter --refresh", "kward openrouter list"]
136
+ },
129
137
  "pan" => {
130
138
  usage: "kward pan",
131
139
  description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
@@ -0,0 +1,55 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # OpenRouter cache management commands for the terminal CLI flow.
6
+ module OpenRouterCommands
7
+ private
8
+
9
+ def handle_openrouter_command(arguments)
10
+ case arguments
11
+ when ["refresh"], ["--refresh"]
12
+ refresh_openrouter_models
13
+ when ["list"], ["--list"]
14
+ list_openrouter_models
15
+ else
16
+ raise ArgumentError, command_usage("openrouter")
17
+ end
18
+ end
19
+
20
+ def refresh_openrouter_models
21
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
22
+ data = cache.refresh
23
+ count = Array(data["models"]).length
24
+ @client.reload_config if @client.respond_to?(:reload_config)
25
+ @prompt.say("Refreshed #{count} OpenRouter text model#{count == 1 ? "" : "s"} for this key.")
26
+ @prompt.say("Cached at: #{cache.path}")
27
+ rescue StandardError => e
28
+ warn e.message
29
+ exit 1
30
+ end
31
+
32
+ def list_openrouter_models
33
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
34
+ data = cache.read
35
+ unless data
36
+ @prompt.say("No OpenRouter model cache found. Run `kward openrouter refresh` first.")
37
+ return
38
+ end
39
+
40
+ models = Array(data["models"])
41
+ lines = ["OpenRouter models cached at #{data["refreshed_at"]}:"]
42
+ lines.concat(models.map { |model| model["id"].to_s }.reject(&:empty?))
43
+ @prompt.say(lines.join("\n"))
44
+ end
45
+
46
+ def configured_openrouter_api_key
47
+ ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(ConfigFiles.read_config, "openrouter_api_key").to_s : ENV["OPENROUTER_API_KEY"].to_s
48
+ end
49
+
50
+ def openrouter_models_cache_path
51
+ File.join(File.dirname(ConfigFiles.config_path), "cache", "openrouter_models.json")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,5 @@
1
+ require "open3"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
@@ -13,7 +15,6 @@ module Kward
13
15
  prompt_interface = load_prompt_interface
14
16
  return unless prompt_interface
15
17
 
16
- banner_enabled = ConfigFiles.banner_enabled?
17
18
  @prompt = prompt_interface.new(
18
19
  slash_commands: slash_command_entries,
19
20
  overlay_settings: ConfigFiles.overlay_settings,
@@ -22,8 +23,7 @@ module Kward
22
23
  busy_help: ConfigFiles.composer_busy_help?,
23
24
  attachment_badges: method(:composer_attachment_badges),
24
25
  attachment_parser: method(:composer_attachment_parser),
25
- banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
26
- banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
26
+ banner_message: Kward::PromptInterface::BANNER_MESSAGE
27
27
  )
28
28
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
29
29
  @prompt.start(render: false)
@@ -50,9 +50,80 @@ module Kward
50
50
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
51
51
  end
52
52
 
53
- # Writes the visual banner output for the terminal CLI flow.
53
+ # Writes the startup info screen output for the terminal CLI flow.
54
54
  def print_visual_banner
55
- @prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
55
+ return unless @prompt.respond_to?(:print_visual_banner)
56
+
57
+ @prompt.print_visual_banner(startup_info_screen)
58
+ end
59
+
60
+ def startup_info_screen
61
+ [
62
+ startup_status_line,
63
+ "",
64
+ startup_info_line("Workspace", startup_workspace_label),
65
+ startup_info_line("Branch", startup_branch_value),
66
+ startup_info_line("Plugins", startup_plugins_value),
67
+ "",
68
+ startup_brand_line
69
+ ].join("\n")
70
+ end
71
+
72
+ def startup_workspace_label
73
+ root = File.expand_path(current_workspace_root)
74
+ home = begin
75
+ Dir.home
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ if home && (root == home || root.start_with?("#{home}/"))
80
+ relative = root.delete_prefix(home).sub(%r{\A/}, "")
81
+ return "~" if relative.empty?
82
+ return "~/#{relative}" unless relative.include?("/")
83
+ end
84
+
85
+ parent = File.basename(File.dirname(root))
86
+ name = File.basename(root)
87
+ parent.empty? || parent == "." ? name : "#{parent}/#{name}"
88
+ end
89
+
90
+ def startup_branch_value
91
+ git_root = startup_git_root(current_workspace_root)
92
+ return "not a repository" if git_root.to_s.empty?
93
+
94
+ branch = startup_git_output(%w[git branch --show-current], root: git_root)
95
+ branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
96
+ branch.empty? ? "unknown" : branch
97
+ end
98
+
99
+ def startup_plugins_value
100
+ filenames = plugin_registry.paths.map { |path| File.basename(path) }
101
+ filenames.empty? ? "none" : filenames.join(", ")
102
+ end
103
+
104
+ def startup_status_line
105
+ "#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
106
+ end
107
+
108
+ def startup_info_line(label, value)
109
+ "#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
110
+ end
111
+
112
+ def startup_brand_line
113
+ ANSI.colorize(Kward::PromptInterface::BANNER_MESSAGE, :bold, enabled: @color_enabled)
114
+ end
115
+
116
+ def startup_git_root(root)
117
+ startup_git_output(%w[git rev-parse --show-toplevel], root: root)
118
+ end
119
+
120
+ def startup_git_output(command, root:)
121
+ output, status = Open3.capture2e(*command, chdir: root.to_s)
122
+ return "" unless status.success?
123
+
124
+ output.lines.first.to_s.strip
125
+ rescue StandardError
126
+ ""
56
127
  end
57
128
 
58
129
  def prompt_footer_renderer
@@ -107,7 +178,10 @@ module Kward
107
178
  def composer_context_window(provider = nil, model = nil)
108
179
  provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
109
180
  model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
110
- ModelInfo.context_window(ModelInfo.provider_label(provider), model)
181
+ provider = ModelInfo.provider_label(provider)
182
+ return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
183
+
184
+ ModelInfo.context_window(provider, model)
111
185
  end
112
186
 
113
187
  def composer_context_usage(provider, model)
@@ -59,7 +59,7 @@ module Kward
59
59
  if prompt_interface?
60
60
  print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
61
61
  else
62
- @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}\n#{summary}\n")
62
+ @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))} #{tool_summary_display_text(summary)}\n")
63
63
  end
64
64
  end
65
65
 
@@ -71,7 +71,7 @@ module Kward
71
71
  print_block_delta(label, rendered)
72
72
  finish_stream_block
73
73
  else
74
- @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}\n#{rendered}\n")
74
+ @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))} #{rendered}\n")
75
75
  end
76
76
  end
77
77
 
@@ -298,8 +298,9 @@ module Kward
298
298
  def print_tool_result(tool_call, content, line_limit: nil)
299
299
  summary = tool_result_summary(tool_call, content)
300
300
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
301
+ display_summary = tool_summary_display_text(summary)
301
302
  if prompt_interface?
302
- summary = summary.end_with?("\n") ? summary : "#{summary}\n"
303
+ summary = display_summary.end_with?("\n") ? display_summary : "#{display_summary}\n"
303
304
  if @prompt.respond_to?(:write_stream_block)
304
305
  @prompt.write_stream_block("Tool", summary, finish: true)
305
306
  else
@@ -309,18 +310,22 @@ module Kward
309
310
  end
310
311
  else
311
312
  start_stream_block(tool_stream_label(content))
312
- print summary
313
- puts unless summary.end_with?("\n")
313
+ print display_summary
314
+ puts unless display_summary.end_with?("\n")
314
315
  $stdout.flush
315
316
  @stream_block = nil
316
317
  end
317
318
  end
318
319
 
320
+ def tool_summary_display_text(summary)
321
+ summary.to_s.sub("\n", "\n\n")
322
+ end
323
+
319
324
  def start_stream_block(label)
320
325
  return if @stream_block == label
321
326
 
322
327
  puts if @stream_block
323
- puts "\n#{colored("#{transcript_label(label)}>", *label_styles(label))}"
328
+ print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
324
329
  @stream_block = label
325
330
  end
326
331
 
@@ -108,6 +108,13 @@ module Kward
108
108
  path = select_session_path(session_store) if path.empty?
109
109
  return nil if path.to_s.empty?
110
110
 
111
+ load_session(session_store, path, message: "Resumed session")
112
+ rescue StandardError => e
113
+ runtime_output("Error: #{e.message}")
114
+ nil
115
+ end
116
+
117
+ def load_session(session_store, path, message: nil)
111
118
  previous_session = @active_session
112
119
  @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
113
120
  reset_session_diff(@active_session.path)
@@ -115,15 +122,12 @@ module Kward
115
122
  cleanup_replaced_session(previous_session)
116
123
  update_assistant_prompt(conversation)
117
124
  restore_prompt_transcript do
118
- runtime_output("Resumed session: #{@active_session.path}")
125
+ runtime_output("#{message}: #{@active_session.path}") if message
119
126
  render_conversation_transcript(conversation)
120
127
  end
121
128
  agent = build_interactive_agent(conversation)
122
129
  @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
123
130
  agent
124
- rescue StandardError => e
125
- runtime_output("Error: #{e.message}")
126
- nil
127
131
  end
128
132
 
129
133
  def navigate_session_tree(session_store)
@@ -295,7 +299,7 @@ module Kward
295
299
  end
296
300
 
297
301
  def relative_rewind_time(timestamp)
298
- time = Time.iso8601(timestamp.to_s).utc
302
+ time = timestamp.is_a?(Time) ? timestamp.utc : Time.iso8601(timestamp.to_s).utc
299
303
  seconds = [(Time.now.utc - time).to_i, 0].max
300
304
  case seconds
301
305
  when 0...60
@@ -397,13 +401,19 @@ module Kward
397
401
  SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
398
402
  end
399
403
 
400
- def rename_session(argument)
404
+ def rename_session(argument, require_name: false)
401
405
  unless @active_session
402
406
  runtime_output("No active persisted session.")
403
407
  return
404
408
  end
405
409
 
406
- @active_session.rename(argument)
410
+ name = argument.to_s.strip
411
+ if require_name && name.empty?
412
+ runtime_output("Usage: /rename <name>")
413
+ return
414
+ end
415
+
416
+ @active_session.rename(name)
407
417
  label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
408
418
  runtime_output(label)
409
419
  end
@@ -420,6 +430,198 @@ module Kward
420
430
  agent
421
431
  end
422
432
 
433
+ def fork_session(session_store)
434
+ return say_sessions_unavailable unless session_store
435
+ unless @active_session
436
+ runtime_output("No active persisted session.")
437
+ return nil
438
+ end
439
+
440
+ points = fork_points(session_store)
441
+ if points.empty?
442
+ runtime_output("No prompts to fork from.")
443
+ return nil
444
+ end
445
+
446
+ point = select_fork_point_from_points(points)
447
+ return nil unless point
448
+
449
+ run_busy_local_command_and_requeue(activity: "forking") do
450
+ fork_session_from_point(session_store, point)
451
+ end
452
+ rescue StandardError => e
453
+ runtime_output("Fork error: #{e.message}")
454
+ nil
455
+ end
456
+
457
+ def fork_points(session_store)
458
+ fork_points_for_session(session_store, @active_session)
459
+ end
460
+
461
+ def fork_points_for_session(session_store, session)
462
+ entries = session_store.session_entries(session.path)
463
+ current_leaf_id = session.leaf_id || session_store.current_leaf(session.path)
464
+ active_path = active_session_tree_entry_ids(entries, current_leaf_id)
465
+ entries.each_with_index.filter_map do |entry, index|
466
+ next unless rewind_entry?(entry)
467
+ next unless active_path.include?(entry["id"].to_s)
468
+
469
+ {
470
+ entry: entry,
471
+ entry_index: index,
472
+ label: fork_point_label(entry),
473
+ timestamp: entry["timestamp"]
474
+ }
475
+ end.reverse.then { |points| align_rewind_point_timestamps(points, picker_choice_width) }
476
+ end
477
+
478
+ def fork_point_label(entry)
479
+ "Fork from: #{truncate_rewind_text(full_message_text(entry["message"] || {}))}"
480
+ end
481
+
482
+ def select_fork_point(labels)
483
+ if @prompt.respond_to?(:select)
484
+ return @prompt.select("Fork>", labels, title: "Fork")
485
+ end
486
+
487
+ numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
488
+ runtime_output((["Fork from:"] + numbered_labels).join("\n"))
489
+ answer = @prompt.ask("Fork point number>").to_s.strip
490
+ answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
491
+ end
492
+
493
+ def fork_session_from_point(session_store, point)
494
+ previous_session = @active_session
495
+ forked_session, conversation, selected_text = create_fork_from_point(session_store, previous_session, point)
496
+ @active_session = track_session(forked_session)
497
+ reset_session_diff(@active_session.path)
498
+ cleanup_replaced_session(previous_session)
499
+ update_assistant_prompt(conversation)
500
+ restore_prompt_transcript do
501
+ runtime_output("Forked session: #{@active_session.path}")
502
+ render_conversation_transcript(conversation)
503
+ end
504
+ prefill_selected_fork_text(selected_text)
505
+ agent = build_interactive_agent(conversation)
506
+ @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
507
+ agent
508
+ end
509
+
510
+ def create_fork_from_point(session_store, source_session, point)
511
+ entries = session_store.session_entries(source_session.path)
512
+ messages = entries[0...point[:entry_index]].filter_map { |entry| entry["message"] }
513
+ forked_session, conversation = session_store.create_independent_from_messages(
514
+ messages,
515
+ provider: current_model_provider,
516
+ model: current_model_id,
517
+ reasoning_effort: current_reasoning_effort,
518
+ parent_session: source_session
519
+ )
520
+ [forked_session, conversation, full_message_text(point[:entry]["message"] || {})]
521
+ end
522
+
523
+ def prefill_selected_fork_text(selected_text)
524
+ return if selected_text.to_s.empty?
525
+
526
+ if @prompt.respond_to?(:prefill_input)
527
+ @prompt.prefill_input(selected_text)
528
+ else
529
+ runtime_output("Selected prompt for editing:\n#{selected_text}")
530
+ end
531
+ end
532
+
533
+ def clone_session_from_path(session_store, path)
534
+ clone_path = clone_session_file_from_path(session_store, path)
535
+ load_session(session_store, clone_path, message: "Cloned session")
536
+ end
537
+
538
+ def fork_session_from_picker(session_store, source_path)
539
+ source_session, = session_store.load(source_path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
540
+ point = select_fork_point_for_session(session_store, source_session)
541
+ return nil unless point
542
+
543
+ forked_session, = create_fork_from_point(session_store, source_session, point)
544
+ forked_session.path
545
+ end
546
+
547
+ def select_fork_point_for_session(session_store, session)
548
+ points = fork_points_for_session(session_store, session)
549
+ if points.empty?
550
+ runtime_output("No prompts to fork from.")
551
+ return nil
552
+ end
553
+
554
+ select_fork_point_from_points(points)
555
+ end
556
+
557
+ def select_fork_point_from_points(points)
558
+ labels = points.map { |point| point[:label] }
559
+ choice = select_fork_point(labels)
560
+ return nil unless choice
561
+
562
+ points[labels.index(choice)]
563
+ end
564
+
565
+ def clone_session_file_from_path(session_store, path)
566
+ source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
567
+ clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
568
+ clone.path
569
+ end
570
+
571
+ def clone_session_selection(session_store, sessions, labels, label)
572
+ copy_session_selection(session_store, sessions, labels, label) do |source|
573
+ clone_session_file_from_path(session_store, source.path)
574
+ end
575
+ end
576
+
577
+ def copy_session_selection(session_store, sessions, labels, label)
578
+ source = sessions[labels.index(label)]
579
+ return nil unless source
580
+
581
+ copy_path = yield source
582
+ insert_session_copy(session_store, sessions, labels, source, copy_path)
583
+ end
584
+
585
+ def insert_session_copy(session_store, sessions, labels, source, copy_path)
586
+ copy_info = session_store.recent_tree(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
587
+ copy_info ||= session_store.recent(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
588
+ return nil unless copy_info
589
+
590
+ source_index = sessions.index(source) || 0
591
+ copy_index = source_index + 1
592
+ sessions.insert(copy_index, copy_info)
593
+ labels.replace(session_picker_labels(sessions))
594
+ continue_session_selection(labels, copy_index)
595
+ end
596
+
597
+ def delete_session_selection(_session_store, sessions, labels, label)
598
+ source = sessions[labels.index(label)]
599
+ return nil unless source
600
+
601
+ SessionTrash.new.delete(source.path)
602
+ index = sessions.index(source) || labels.index(label) || 0
603
+ sessions.delete_at(index)
604
+ labels.replace(session_picker_labels(sessions))
605
+ next_index = [index, labels.length - 1].min
606
+ continue_session_selection(labels, next_index)
607
+ end
608
+
609
+ def rename_session_selection(session_store, sessions, labels, label, name)
610
+ source = sessions[labels.index(label)]
611
+ return nil unless source
612
+
613
+ session_store.load(source.path).first.rename(name)
614
+ updated = session_store.recent_tree(limit: nil)
615
+ sessions.replace(updated)
616
+ labels.replace(session_picker_labels(sessions))
617
+ index = sessions.index { |session| File.expand_path(session.path) == File.expand_path(source.path) } || 0
618
+ continue_session_selection(labels, index)
619
+ end
620
+
621
+ def continue_session_selection(labels, selection_index)
622
+ { select_continue: true, choices: labels, selection_index: selection_index }
623
+ end
624
+
423
625
  def copy_session_text(conversation, argument)
424
626
  target = copy_target(argument)
425
627
  unless target
@@ -501,19 +703,46 @@ module Kward
501
703
  end
502
704
 
503
705
  def select_session_path(session_store)
504
- select_session_path_from_sessions(session_store.recent_tree(limit: nil))
706
+ select_session_path_from_sessions(session_store.recent_tree(limit: nil), session_store: session_store)
505
707
  end
506
708
 
507
- def select_session_path_from_sessions(sessions)
709
+ def reopen_sessions_after_fork(session_store, source_path, source_label)
710
+ fork_path = run_busy_local_command_and_requeue(activity: "forking") do
711
+ fork_session_from_picker(session_store, source_path)
712
+ end
713
+
714
+ sessions = session_store.recent_tree(limit: nil)
715
+ labels = session_picker_labels(sessions)
716
+ initial_index = if fork_path
717
+ sessions.index { |session| File.expand_path(session.path) == File.expand_path(fork_path) }
718
+ else
719
+ labels.index(source_label)
720
+ end
721
+ select_session_path_from_sessions(sessions, session_store: session_store, initial_index: initial_index || 0)
722
+ end
723
+
724
+ def select_session_path_from_sessions(sessions, session_store: @session_store, initial_index: 0)
508
725
  if sessions.empty?
509
726
  runtime_output("No saved sessions found.")
510
727
  return nil
511
728
  end
512
729
 
513
- labels = sessions.map { |session| session_label(session) }
730
+ labels = session_picker_labels(sessions)
514
731
  if @prompt.respond_to?(:select)
515
- choice = @prompt.select("Session>", labels)
732
+ choice = @prompt.select(
733
+ "Session>",
734
+ labels,
735
+ initial_index: initial_index,
736
+ action_keys: { "c" => { action: :clone, activity: "cloning" }, "f" => { action: :fork, defer_finish_render: true }, "r" => { action: :rename, input_prompt: "Name>" }, "d" => { action: :delete, confirm: "Press d again to delete, Esc to cancel.", confirm_title: "Delete session?" } },
737
+ action_handlers: {
738
+ clone: ->(label) { clone_session_selection(session_store, sessions, labels, label) },
739
+ delete: ->(label) { delete_session_selection(session_store, sessions, labels, label) },
740
+ rename: ->(label, name) { rename_session_selection(session_store, sessions, labels, label, name) }
741
+ }
742
+ )
516
743
  return nil unless choice
744
+ return choice if choice.respond_to?(:conversation)
745
+ return choice[:path] ? choice : session_selection_action(choice, sessions, labels, defer_finish_render: choice[:defer_finish_render]) if choice.is_a?(Hash)
517
746
 
518
747
  selected = sessions[labels.index(choice)]
519
748
  return selected&.path
@@ -529,6 +758,26 @@ module Kward
529
758
  end
530
759
  end
531
760
 
761
+ def session_selection_action(choice, sessions, labels, defer_finish_render: false)
762
+ selected = sessions[labels.index(choice[:choice])]
763
+ return nil unless selected
764
+
765
+ { action: choice[:action], path: selected.path, choice_label: choice[:choice] }.tap do |action|
766
+ action[:defer_finish_render] = true if defer_finish_render
767
+ end
768
+ end
769
+
770
+ def session_picker_labels(sessions)
771
+ labels = sessions.map { |session| session_label(session) }
772
+ label_width = labels.map(&:length).max.to_i
773
+ sessions.zip(labels).map do |session, label|
774
+ timestamp = relative_rewind_time(session.modified_at)
775
+ next label if timestamp.empty?
776
+
777
+ right_aligned_picker_metadata(label, timestamp, width: picker_choice_width, minimum_label_width: label_width)
778
+ end
779
+ end
780
+
532
781
  def session_label(session)
533
782
  title = session.name.to_s.strip
534
783
  title = session.first_message.to_s.strip if title.empty?
@@ -222,9 +222,6 @@ module Kward
222
222
  when /\Ashow busy help/, /\Ahide busy help/
223
223
  set_composer_busy_help(!composer_busy_help?)
224
224
  runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
225
- when /\Ashow startup banner/, /\Ahide startup banner/
226
- set_banner_enabled(!banner_enabled?)
227
- runtime_output("Startup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
228
225
  when /\Aenable session auto-resume/, /\Adisable session auto-resume/
229
226
  set_session_auto_resume_enabled(!session_auto_resume_enabled?)
230
227
  runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
@@ -237,7 +234,6 @@ module Kward
237
234
  "Overlay alignment (#{settings["alignment"]})",
238
235
  "Overlay width (#{settings["width"]})",
239
236
  "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
240
- "#{banner_enabled? ? "Hide" : "Show"} startup banner (currently #{on_off(banner_enabled?)})",
241
237
  "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
242
238
  "Back"
243
239
  ]
@@ -247,10 +243,6 @@ module Kward
247
243
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
248
244
  end
249
245
 
250
- def banner_enabled?
251
- ConfigFiles.banner_enabled?(safely_read_config.to_h)
252
- end
253
-
254
246
  def session_auto_resume_enabled?
255
247
  ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
256
248
  end
@@ -259,10 +251,6 @@ module Kward
259
251
  update_nested_config("composer", "busy_help" => enabled)
260
252
  end
261
253
 
262
- def set_banner_enabled(enabled)
263
- update_nested_config("banner", "enabled" => enabled)
264
- end
265
-
266
254
  def set_session_auto_resume_enabled(enabled)
267
255
  update_nested_config("sessions", "auto_resume" => enabled)
268
256
  end
@@ -518,24 +506,6 @@ module Kward
518
506
  runtime_output("Model error: #{e.message}")
519
507
  end
520
508
 
521
- # Writes the openrouter catalog output for the terminal CLI flow.
522
- def print_openrouter_catalog
523
- unless @client.respond_to?(:openrouter_catalog)
524
- runtime_output("OpenRouter catalog is unavailable for this client.")
525
- return
526
- end
527
-
528
- models = Array(@client.openrouter_catalog)
529
- if models.empty?
530
- runtime_output("No OpenRouter catalog models available.")
531
- else
532
- ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
533
- runtime_output((["OpenRouter catalog:"] + ids).join("\n"))
534
- end
535
- rescue StandardError => e
536
- runtime_output("OpenRouter catalog error: #{e.message}")
537
- end
538
-
539
509
  def configure_reasoning(conversation = nil)
540
510
  unless model_overlay_available?
541
511
  runtime_output("Reasoning overlay is unavailable in this prompt.")