kward 0.69.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +68 -0
  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 +2 -16
  12. data/doc/context-tools.md +70 -0
  13. data/doc/getting-started.md +3 -1
  14. data/doc/plugins.md +2 -2
  15. data/doc/releasing.md +14 -5
  16. data/doc/rpc.md +3 -11
  17. data/doc/session-management.md +220 -0
  18. data/doc/usage.md +13 -7
  19. data/doc/workspace-tools.md +105 -0
  20. data/lib/kward/cli/commands.rb +8 -0
  21. data/lib/kward/cli/openrouter_commands.rb +55 -0
  22. data/lib/kward/cli/prompt_interface.rb +85 -7
  23. data/lib/kward/cli/rendering.rb +11 -6
  24. data/lib/kward/cli/sessions.rb +454 -15
  25. data/lib/kward/cli/settings.rb +0 -30
  26. data/lib/kward/cli/slash_commands.rb +38 -11
  27. data/lib/kward/cli.rb +14 -0
  28. data/lib/kward/compactor.rb +4 -1
  29. data/lib/kward/config_files.rb +4 -6
  30. data/lib/kward/conversation.rb +49 -5
  31. data/lib/kward/model/client.rb +37 -50
  32. data/lib/kward/model/context_usage.rb +13 -6
  33. data/lib/kward/model/model_info.rb +92 -9
  34. data/lib/kward/model/payloads.rb +2 -0
  35. data/lib/kward/openrouter_model_cache.rb +120 -0
  36. data/lib/kward/plugin_registry.rb +47 -1
  37. data/lib/kward/prompt_interface/banner.rb +16 -51
  38. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  39. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  40. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  41. data/lib/kward/prompt_interface/layout.rb +2 -2
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
  43. data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
  44. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  45. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  46. data/lib/kward/prompt_interface/screen.rb +10 -4
  47. data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
  48. data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
  49. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  50. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  51. data/lib/kward/prompt_interface.rb +31 -32
  52. data/lib/kward/prompts/commands.rb +6 -3
  53. data/lib/kward/prompts.rb +2 -2
  54. data/lib/kward/rpc/server.rb +3 -8
  55. data/lib/kward/rpc/session_manager.rb +19 -8
  56. data/lib/kward/session_diff.rb +106 -9
  57. data/lib/kward/session_store.rb +23 -4
  58. data/lib/kward/session_tree_renderer.rb +2 -1
  59. data/lib/kward/telemetry/logger.rb +5 -3
  60. data/lib/kward/tool_output_compactor.rb +127 -0
  61. data/lib/kward/tools/base.rb +8 -2
  62. data/lib/kward/tools/registry.rb +37 -6
  63. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  64. data/lib/kward/tools/search/web.rb +2 -2
  65. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  66. data/lib/kward/tools/tool_call.rb +2 -0
  67. data/lib/kward/version.rb +1 -1
  68. data/lib/kward/workspace.rb +58 -2
  69. data/templates/default/fulldoc/html/css/kward.css +570 -78
  70. data/templates/default/fulldoc/html/full_list.erb +107 -0
  71. data/templates/default/fulldoc/html/js/kward.js +259 -97
  72. data/templates/default/fulldoc/html/setup.rb +8 -0
  73. data/templates/default/kward_navigation.rb +91 -0
  74. data/templates/default/layout/html/layout.erb +59 -13
  75. data/templates/default/layout/html/setup.rb +34 -39
  76. metadata +13 -3
  77. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  78. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -1,3 +1,5 @@
1
+ require "time"
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.
@@ -106,6 +108,13 @@ module Kward
106
108
  path = select_session_path(session_store) if path.empty?
107
109
  return nil if path.to_s.empty?
108
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)
109
118
  previous_session = @active_session
110
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)
111
120
  reset_session_diff(@active_session.path)
@@ -113,15 +122,12 @@ module Kward
113
122
  cleanup_replaced_session(previous_session)
114
123
  update_assistant_prompt(conversation)
115
124
  restore_prompt_transcript do
116
- runtime_output("Resumed session: #{@active_session.path}")
125
+ runtime_output("#{message}: #{@active_session.path}") if message
117
126
  render_conversation_transcript(conversation)
118
127
  end
119
128
  agent = build_interactive_agent(conversation)
120
129
  @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
121
130
  agent
122
- rescue StandardError => e
123
- runtime_output("Error: #{e.message}")
124
- nil
125
131
  end
126
132
 
127
133
  def navigate_session_tree(session_store)
@@ -131,7 +137,7 @@ module Kward
131
137
  return nil
132
138
  end
133
139
 
134
- tree_items = session_tree_items(session_store)
140
+ tree_items = run_busy_local_command_and_requeue { session_tree_items(session_store) }
135
141
  if tree_items.empty?
136
142
  runtime_output("No session tree entries found.")
137
143
  return nil
@@ -147,8 +153,12 @@ module Kward
147
153
  entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
148
154
  return nil unless entry
149
155
 
150
- selected_text = apply_session_tree_entry(entry)
151
- runtime_output("Moved session tree position to #{entry["id"]}.")
156
+ selected_text = nil
157
+ agent = run_busy_local_command_and_requeue do
158
+ selected_text = apply_session_tree_entry(entry)
159
+ runtime_output("Moved session tree position to #{entry["id"]}.")
160
+ reload_active_session(session_store)
161
+ end
152
162
  if selected_text && !selected_text.empty?
153
163
  if @prompt.respond_to?(:prefill_input)
154
164
  @prompt.prefill_input(selected_text)
@@ -156,8 +166,7 @@ module Kward
156
166
  runtime_output("Selected text for editing:\n#{selected_text}")
157
167
  end
158
168
  end
159
- agent = reload_active_session(session_store)
160
- @prompt.redraw if @prompt.respond_to?(:redraw)
169
+ @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
161
170
  agent
162
171
  rescue StandardError => e
163
172
  runtime_output("Session tree error: #{e.message}")
@@ -175,11 +184,193 @@ module Kward
175
184
  answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
176
185
  end
177
186
 
187
+ def rewind_session(session_store)
188
+ return say_sessions_unavailable unless session_store
189
+ unless @active_session
190
+ runtime_output("No active persisted session.")
191
+ return nil
192
+ end
193
+
194
+ points = rewind_points(session_store)
195
+ if points.empty?
196
+ runtime_output("No prompts to rewind to.")
197
+ return nil
198
+ end
199
+
200
+ labels = points.map { |point| point[:label] }
201
+ choice = select_rewind_point(labels)
202
+ return nil unless choice
203
+
204
+ point = points[labels.index(choice)]
205
+ return nil unless point
206
+
207
+ if point[:return_leaf_id]
208
+ @active_session.branch(point[:return_leaf_id])
209
+ @rewind_return_leaf_id = nil
210
+ else
211
+ @rewind_return_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
212
+ selected_text = apply_session_tree_entry(point[:entry])
213
+ if selected_text && !selected_text.empty?
214
+ if @prompt.respond_to?(:prefill_input)
215
+ @prompt.prefill_input(selected_text)
216
+ else
217
+ runtime_output("Selected prompt for editing:\n#{selected_text}")
218
+ end
219
+ end
220
+ end
221
+ agent = reload_active_session(session_store)
222
+ @prompt.redraw if @prompt.respond_to?(:redraw)
223
+ agent
224
+ rescue StandardError => e
225
+ runtime_output("Rewind error: #{e.message}")
226
+ nil
227
+ end
228
+
229
+ def select_rewind_point(labels)
230
+ if @prompt.respond_to?(:select)
231
+ return @prompt.select("Rewind>", labels, title: "Rewind")
232
+ end
233
+
234
+ numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
235
+ runtime_output((["Rewind to:"] + numbered_labels).join("\n"))
236
+ answer = @prompt.ask("Rewind point number>").to_s.strip
237
+ answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
238
+ end
239
+
240
+ def rewind_points(session_store)
241
+ entries = session_store.session_entries(@active_session.path)
242
+ current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
243
+ active_path = active_session_tree_entry_ids(entries, current_leaf_id)
244
+ user_entries = entries.select { |entry| rewind_entry?(entry) }
245
+ points = user_entries.reverse_each.with_index.map do |entry, index|
246
+ {
247
+ entry: entry,
248
+ label: rewind_point_label(entry, index, active_path.include?(entry["id"].to_s)),
249
+ timestamp: entry["timestamp"]
250
+ }
251
+ end
252
+ return_point = rewind_return_point(entries, current_leaf_id)
253
+ points = [return_point] + points if return_point
254
+ align_rewind_point_timestamps(points, picker_choice_width)
255
+ end
256
+
257
+ def rewind_return_point(entries, current_leaf_id)
258
+ return nil if @rewind_return_leaf_id.to_s.empty?
259
+ return nil if @rewind_return_leaf_id.to_s == current_leaf_id.to_s
260
+
261
+ entry = entries.find { |candidate| candidate["id"].to_s == @rewind_return_leaf_id.to_s }
262
+ return nil unless entry
263
+
264
+ {
265
+ return_leaf_id: @rewind_return_leaf_id,
266
+ label: "Return to where I was: #{truncate_rewind_text(rewind_return_text(entry))}",
267
+ timestamp: entry["timestamp"]
268
+ }
269
+ end
270
+
271
+ def align_rewind_point_timestamps(points, width)
272
+ labels = points.map { |point| point[:label].to_s }
273
+ label_width = labels.map(&:length).max.to_i
274
+ points.each do |point|
275
+ timestamp = relative_rewind_time(point[:timestamp])
276
+ next if timestamp.empty?
277
+
278
+ point[:label] = right_aligned_picker_metadata(point[:label], timestamp, width: width, minimum_label_width: label_width)
279
+ end
280
+ end
281
+
282
+ def right_aligned_picker_metadata(label, metadata, width:, minimum_label_width: 0)
283
+ label = label.to_s
284
+ metadata = metadata.to_s
285
+ fallback_width = minimum_label_width + metadata.length + 2
286
+ target_width = width.to_i.positive? ? width.to_i : fallback_width
287
+ label_width = [target_width - metadata.length - 2, 1].max
288
+ "#{truncate_picker_label(label, label_width).ljust(label_width)} #{metadata}"
289
+ end
290
+
291
+ def truncate_picker_label(label, width)
292
+ return "" if width <= 0
293
+
294
+ text = label.to_s
295
+ return text if text.length <= width
296
+ return text.slice(0, width) if width <= 3
297
+
298
+ "#{text.slice(0, width - 3)}..."
299
+ end
300
+
301
+ def relative_rewind_time(timestamp)
302
+ time = timestamp.is_a?(Time) ? timestamp.utc : Time.iso8601(timestamp.to_s).utc
303
+ seconds = [(Time.now.utc - time).to_i, 0].max
304
+ case seconds
305
+ when 0...60
306
+ "just now"
307
+ when 60...3600
308
+ minutes = seconds / 60
309
+ "#{minutes} min ago"
310
+ when 3600...86_400
311
+ hours = seconds / 3600
312
+ "#{hours} h ago"
313
+ else
314
+ days = seconds / 86_400
315
+ "#{days} d ago"
316
+ end
317
+ rescue ArgumentError
318
+ ""
319
+ end
320
+
321
+ def rewind_return_text(entry)
322
+ message = entry["message"]
323
+ text = full_message_text(message) if message.is_a?(Hash)
324
+ text.to_s.empty? ? entry["id"].to_s : text
325
+ end
326
+
327
+ def rewind_entry?(entry)
328
+ return false unless entry["type"] == "message"
329
+
330
+ message = entry["message"]
331
+ message.is_a?(Hash) && message_role(message) == "user" && !full_message_text(message).empty?
332
+ end
333
+
334
+ def rewind_point_label(entry, index, active)
335
+ marker = active ? "• " : ""
336
+ prefix = case index
337
+ when 0 then "Last prompt"
338
+ when 1 then "2 turns ago"
339
+ else "#{index + 1} turns ago"
340
+ end
341
+ "#{marker}#{prefix}: #{truncate_rewind_text(full_message_text(entry["message"] || {}))}"
342
+ end
343
+
344
+ def active_session_tree_entry_ids(entries, leaf_id)
345
+ by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
346
+ ids = []
347
+ seen = {}
348
+ current = by_id[leaf_id.to_s]
349
+ while current && !seen[current["id"].to_s]
350
+ seen[current["id"].to_s] = true
351
+ ids << current["id"].to_s
352
+ current = by_id[current["parentId"].to_s]
353
+ end
354
+ ids
355
+ end
356
+
357
+ def truncate_rewind_text(text)
358
+ text.to_s.gsub(/\s+/, " ").strip
359
+ end
360
+
361
+ def picker_choice_width
362
+ if @prompt.respond_to?(:picker_choice_width)
363
+ @prompt.picker_choice_width
364
+ else
365
+ 96
366
+ end
367
+ end
368
+
178
369
  def apply_session_tree_entry(entry)
179
370
  message = entry["message"]
180
371
  if message.is_a?(Hash) && message_role(message) == "user"
181
372
  target_leaf = entry["parentId"]
182
- target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
373
+ @active_session.branch(target_leaf) unless target_leaf.to_s.empty?
183
374
  return full_message_text(message)
184
375
  end
185
376
 
@@ -210,13 +401,19 @@ module Kward
210
401
  SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
211
402
  end
212
403
 
213
- def rename_session(argument)
404
+ def rename_session(argument, require_name: false)
214
405
  unless @active_session
215
406
  runtime_output("No active persisted session.")
216
407
  return
217
408
  end
218
409
 
219
- @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)
220
417
  label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
221
418
  runtime_output(label)
222
419
  end
@@ -233,6 +430,198 @@ module Kward
233
430
  agent
234
431
  end
235
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
+
236
625
  def copy_session_text(conversation, argument)
237
626
  target = copy_target(argument)
238
627
  unless target
@@ -314,16 +703,46 @@ module Kward
314
703
  end
315
704
 
316
705
  def select_session_path(session_store)
317
- sessions = session_store.recent(limit: nil)
706
+ select_session_path_from_sessions(session_store.recent_tree(limit: nil), session_store: session_store)
707
+ end
708
+
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)
318
725
  if sessions.empty?
319
726
  runtime_output("No saved sessions found.")
320
727
  return nil
321
728
  end
322
729
 
323
- labels = sessions.map { |session| session_label(session) }
730
+ labels = session_picker_labels(sessions)
324
731
  if @prompt.respond_to?(:select)
325
- 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
+ )
326
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)
327
746
 
328
747
  selected = sessions[labels.index(choice)]
329
748
  return selected&.path
@@ -339,6 +758,26 @@ module Kward
339
758
  end
340
759
  end
341
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
+
342
781
  def session_label(session)
343
782
  title = session.name.to_s.strip
344
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.")
@@ -32,9 +32,6 @@ module Kward
32
32
  models = run_busy_local_command_and_requeue { normalized_available_models }
33
33
  configure_model(agent.conversation, models: models)
34
34
  [true, nil]
35
- when "openrouter/catalog"
36
- run_busy_local_command_and_requeue { print_openrouter_catalog }
37
- [true, nil]
38
35
  when "reasoning"
39
36
  configure_reasoning(agent.conversation)
40
37
  [true, nil]
@@ -43,19 +40,49 @@ module Kward
43
40
  [true, nil]
44
41
  when "new"
45
42
  [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
46
- when "resume"
47
- [true, run_busy_local_command_and_requeue do
48
- path = argument.to_s.strip
49
- path = select_session_path(session_store) if session_store && path.empty?
50
- resume_session(session_store, path)
51
- end]
43
+ when "sessions", "resume"
44
+ unless session_store
45
+ say_sessions_unavailable
46
+ return [true, nil]
47
+ end
48
+
49
+ path = argument.to_s.strip
50
+ if path.empty?
51
+ sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
52
+ path = select_session_path_from_sessions(sessions, session_store: session_store)
53
+ end
54
+ replacement_agent = nil
55
+ selection = path
56
+ loop do
57
+ replacement_agent = if selection.respond_to?(:conversation)
58
+ selection
59
+ elsif selection.is_a?(Hash) && selection[:action] == :clone
60
+ run_busy_local_command_and_requeue(activity: "cloning") { clone_session_from_path(session_store, selection[:path]) }
61
+ elsif selection.is_a?(Hash) && selection[:action] == :fork
62
+ selection = reopen_sessions_after_fork(session_store, selection[:path], selection[:choice_label])
63
+ next
64
+ elsif selection.to_s.empty?
65
+ nil
66
+ else
67
+ run_busy_local_command_and_requeue { resume_session(session_store, selection) }
68
+ end
69
+ break
70
+ end
71
+ [true, replacement_agent]
52
72
  when "name"
53
- run_busy_local_command_and_requeue { rename_session(argument) }
73
+ rename_session(argument)
74
+ [true, nil]
75
+ when "rename"
76
+ rename_session(argument, require_name: true)
54
77
  [true, nil]
55
78
  when "clone"
56
79
  [true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
80
+ when "fork"
81
+ [true, fork_session(session_store)]
82
+ when "rewind"
83
+ [true, run_busy_local_command_and_requeue { rewind_session(session_store) }]
57
84
  when "tree"
58
- [true, run_busy_local_command_and_requeue { navigate_session_tree(session_store) }]
85
+ [true, navigate_session_tree(session_store)]
59
86
  when "copy"
60
87
  run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
61
88
  [true, nil]