kward 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -12,7 +12,7 @@ module Kward
12
12
  return @session_store if @session_store
13
13
  return nil if agent
14
14
 
15
- SessionStore.new
15
+ @session_store = SessionStore.new
16
16
  end
17
17
 
18
18
  def resume_last_session(session_store)
@@ -69,7 +69,7 @@ module Kward
69
69
  end
70
70
 
71
71
  def mutation_tool_call?(tool_call)
72
- ["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
72
+ ToolCall.file_change_tool?(ToolCall.name(tool_call))
73
73
  end
74
74
 
75
75
  def cleanup_unused_sessions
@@ -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,21 @@ 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.")
225
+ when /\Atab keybindings/
226
+ configure_tab_keybindings
227
+ when /\Aeditor mode/
228
+ configure_editor_mode
229
+ when /\Aeditor line numbers/
230
+ configure_editor_line_numbers
231
+ when /\Aenable auto-close pairs/, /\Adisable auto-close pairs/
232
+ set_editor_auto_close_pairs_enabled(!editor_auto_close_pairs_enabled?)
233
+ runtime_output("Editor auto-close pairs #{editor_auto_close_pairs_enabled? ? "enabled" : "disabled"}.")
234
+ when /\Aenable soft-wrap/, /\Adisable soft-wrap/
235
+ set_editor_soft_wrap_enabled(!editor_soft_wrap_enabled?)
236
+ runtime_output("Editor soft-wrap #{editor_soft_wrap_enabled? ? "enabled" : "disabled"}.")
237
+ when /\Aenable bar cursor/, /\Adisable bar cursor/
238
+ set_editor_bar_cursor_enabled(!editor_bar_cursor_enabled?)
239
+ runtime_output("Editor bar cursor #{editor_bar_cursor_enabled? ? "enabled" : "disabled"}.")
228
240
  when /\Aenable session auto-resume/, /\Adisable session auto-resume/
229
241
  set_session_auto_resume_enabled(!session_auto_resume_enabled?)
230
242
  runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
@@ -237,7 +249,12 @@ module Kward
237
249
  "Overlay alignment (#{settings["alignment"]})",
238
250
  "Overlay width (#{settings["width"]})",
239
251
  "#{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?)})",
252
+ "Tab keybindings (#{composer_tab_keybindings})",
253
+ "Editor mode (#{editor_mode})",
254
+ "Editor line numbers (#{editor_line_numbers})",
255
+ "#{editor_auto_close_pairs_enabled? ? "Disable" : "Enable"} auto-close pairs (currently #{on_off(editor_auto_close_pairs_enabled?)})",
256
+ "#{editor_soft_wrap_enabled? ? "Disable" : "Enable"} soft-wrap (currently #{on_off(editor_soft_wrap_enabled?)})",
257
+ "#{editor_bar_cursor_enabled? ? "Disable" : "Enable"} bar cursor (currently #{on_off(editor_bar_cursor_enabled?)})",
241
258
  "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
242
259
  "Back"
243
260
  ]
@@ -247,8 +264,82 @@ module Kward
247
264
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
248
265
  end
249
266
 
250
- def banner_enabled?
251
- ConfigFiles.banner_enabled?(safely_read_config.to_h)
267
+ def composer_tab_keybindings
268
+ ConfigFiles.composer_tab_keybindings(safely_read_config.to_h)
269
+ end
270
+
271
+ def configure_tab_keybindings
272
+ selected = @prompt.select("Tab keybindings", tab_keybinding_choices, title: "Settings")
273
+ value = selected.to_s.split.first.to_s.downcase
274
+ return unless %w[auto ctrl alt].include?(value)
275
+
276
+ update_nested_config("composer", "tab_keybindings" => value)
277
+ runtime_output("Tab keybindings set to #{value}. Restart the TUI to apply this setting.")
278
+ end
279
+
280
+ def tab_keybinding_choices
281
+ current = composer_tab_keybindings
282
+ %w[auto ctrl alt].map { |value| value == current ? "#{value} (current)" : value }
283
+ end
284
+
285
+ def editor_mode
286
+ ConfigFiles.editor_mode(safely_read_config.to_h)
287
+ end
288
+
289
+ def configure_editor_mode
290
+ selected = @prompt.select("Editor mode", editor_mode_choices, title: "Settings")
291
+ value = selected.to_s.split.first.to_s.downcase
292
+ return unless %w[modern emacs vibe].include?(value)
293
+
294
+ update_nested_config("editor", "mode" => value)
295
+ runtime_output("Editor mode set to #{value}. New editor buffers will use this mode.")
296
+ end
297
+
298
+ def editor_mode_choices
299
+ current = editor_mode
300
+ %w[modern emacs vibe].map { |value| value == current ? "#{value} (current)" : value }
301
+ end
302
+
303
+ def editor_line_numbers
304
+ ConfigFiles.editor_line_numbers(safely_read_config.to_h)
305
+ end
306
+
307
+ def configure_editor_line_numbers
308
+ selected = @prompt.select("Editor line numbers", editor_line_number_choices, title: "Settings")
309
+ value = selected.to_s.split.first.to_s.downcase
310
+ return unless %w[absolute relative].include?(value)
311
+
312
+ update_nested_config("editor", "line_numbers" => value)
313
+ runtime_output("Editor line numbers set to #{value}.")
314
+ end
315
+
316
+ def editor_line_number_choices
317
+ current = editor_line_numbers
318
+ %w[absolute relative].map { |value| value == current ? "#{value} (current)" : value }
319
+ end
320
+
321
+ def editor_auto_close_pairs_enabled?
322
+ ConfigFiles.editor_auto_close_pairs?(safely_read_config.to_h)
323
+ end
324
+
325
+ def set_editor_auto_close_pairs_enabled(enabled)
326
+ update_nested_config("editor", "auto_close_pairs" => enabled)
327
+ end
328
+
329
+ def editor_soft_wrap_enabled?
330
+ ConfigFiles.editor_soft_wrap?(safely_read_config.to_h)
331
+ end
332
+
333
+ def set_editor_soft_wrap_enabled(enabled)
334
+ update_nested_config("editor", "soft_wrap" => enabled)
335
+ end
336
+
337
+ def editor_bar_cursor_enabled?
338
+ ConfigFiles.editor_bar_cursor?(safely_read_config.to_h)
339
+ end
340
+
341
+ def set_editor_bar_cursor_enabled(enabled)
342
+ update_nested_config("editor", "bar_cursor" => enabled)
252
343
  end
253
344
 
254
345
  def session_auto_resume_enabled?
@@ -259,10 +350,6 @@ module Kward
259
350
  update_nested_config("composer", "busy_help" => enabled)
260
351
  end
261
352
 
262
- def set_banner_enabled(enabled)
263
- update_nested_config("banner", "enabled" => enabled)
264
- end
265
-
266
353
  def set_session_auto_resume_enabled(enabled)
267
354
  update_nested_config("sessions", "auto_resume" => enabled)
268
355
  end
@@ -467,11 +554,7 @@ module Kward
467
554
  end
468
555
 
469
556
  def update_nested_config(section, values)
470
- config = ConfigFiles.read_config
471
- current = config[section].is_a?(Hash) ? config[section].dup : {}
472
- config[section] = current.merge(values)
473
- ConfigFiles.write_config(config)
474
- config
557
+ ConfigFiles.update_nested_config(section, values)
475
558
  end
476
559
 
477
560
  def on_off(value)
@@ -518,24 +601,6 @@ module Kward
518
601
  runtime_output("Model error: #{e.message}")
519
602
  end
520
603
 
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
604
  def configure_reasoning(conversation = nil)
540
605
  unless model_overlay_available?
541
606
  runtime_output("Reasoning overlay is unavailable in this prompt.")
@@ -556,10 +621,7 @@ module Kward
556
621
  effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
557
622
  raise "Reasoning effort must be one of: #{choices.map(&:first).join(", ")}" unless effort
558
623
 
559
- ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
560
- reload_client_config
561
- refresh_conversation_runtime(conversation)
562
- @prompt.redraw if @prompt.respond_to?(:redraw)
624
+ set_reasoning_effort(effort, conversation, provider: provider)
563
625
  rescue StandardError => e
564
626
  runtime_output("Reasoning error: #{e.message}")
565
627
  end
@@ -639,6 +701,123 @@ module Kward
639
701
  end
640
702
  end
641
703
 
704
+ REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
705
+
706
+ def cycle_reasoning(conversation = current_footer_conversation, direction: :next, persist: :immediate)
707
+ provider = conversation&.provider || current_model_provider
708
+ model = conversation&.model || current_model_id
709
+ choices = ModelInfo.reasoning_effort_choices(provider, model)
710
+ return false if choices.empty?
711
+
712
+ current = (pending_reasoning_effort(provider) || conversation&.reasoning_effort || current_reasoning_effort).to_s
713
+ current_index = choices.index { |effort, _label| effort == current }
714
+ current_index ||= direction == :previous ? 0 : -1
715
+ offset = direction == :previous ? -1 : 1
716
+ effort = choices[(current_index + offset) % choices.length].first
717
+ persist == :debounced ? apply_reasoning_effort(effort, conversation, provider: provider) : set_reasoning_effort(effort, conversation, provider: provider)
718
+ true
719
+ rescue StandardError => e
720
+ runtime_output("Reasoning error: #{e.message}")
721
+ false
722
+ end
723
+
724
+ def set_reasoning_effort(effort, conversation = nil, provider: nil)
725
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = nil }
726
+ persist_reasoning_config(effort, provider: provider)
727
+ apply_reasoning_effort(effort, conversation, provider: provider, queue_config: false)
728
+ end
729
+
730
+ def apply_reasoning_effort(effort, conversation = nil, provider: nil, queue_config: true)
731
+ queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
732
+ if queue_config
733
+ update_conversation_reasoning_effort(conversation, effort)
734
+ refresh_reasoning_status
735
+ else
736
+ refresh_conversation_runtime(conversation, reasoning_effort: effort)
737
+ conversation.persist_runtime_context! if conversation&.respond_to?(:persist_runtime_context!)
738
+ @prompt.redraw if @prompt.respond_to?(:redraw)
739
+ end
740
+ end
741
+
742
+ def refresh_reasoning_status
743
+ if @prompt.respond_to?(:refresh_composer_status)
744
+ @prompt.refresh_composer_status
745
+ else
746
+ @prompt.redraw if @prompt.respond_to?(:redraw)
747
+ end
748
+ end
749
+
750
+ def update_conversation_reasoning_effort(conversation, effort)
751
+ return unless conversation&.respond_to?(:update_runtime_context!)
752
+
753
+ conversation.update_runtime_context!(
754
+ provider: conversation.provider || current_model_provider,
755
+ model: conversation.model || current_model_id,
756
+ reasoning_effort: effort,
757
+ refresh: false
758
+ )
759
+ end
760
+
761
+ def pending_reasoning_effort(provider)
762
+ @pending_reasoning_config_mutex.synchronize do
763
+ pending = @pending_reasoning_config
764
+ return nil unless pending
765
+ return nil unless pending[:provider].to_s.downcase == provider.to_s.downcase
766
+
767
+ pending[:effort]
768
+ end
769
+ end
770
+
771
+ def queue_reasoning_config(effort, provider: nil, conversation: nil)
772
+ pending = {
773
+ effort: effort,
774
+ provider: provider || current_model_provider,
775
+ conversation: conversation,
776
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + REASONING_CONFIG_DEBOUNCE_SECONDS
777
+ }
778
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = pending }
779
+ schedule_reasoning_config_flush
780
+ end
781
+
782
+ def schedule_reasoning_config_flush
783
+ return if @pending_reasoning_config_thread&.alive?
784
+
785
+ @pending_reasoning_config_thread = Thread.new do
786
+ loop do
787
+ sleep REASONING_CONFIG_DEBOUNCE_SECONDS
788
+ break if flush_pending_reasoning_config(force: false)
789
+ break unless @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config }
790
+ end
791
+ rescue StandardError => e
792
+ runtime_output("Reasoning error: #{e.message}")
793
+ end
794
+ end
795
+
796
+ def flush_pending_reasoning_config(force: true, conversation: nil)
797
+ pending = nil
798
+ @pending_reasoning_config_mutex.synchronize do
799
+ pending = @pending_reasoning_config
800
+ return false unless pending
801
+
802
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
803
+ return false if !force && now < pending[:deadline].to_f
804
+
805
+ @pending_reasoning_config = nil
806
+ end
807
+ persist_reasoning_config(pending[:effort], provider: pending[:provider])
808
+ conversation ||= pending[:conversation]
809
+ if conversation&.reasoning_effort.to_s == pending[:effort].to_s
810
+ refresh_conversation_runtime(conversation, reasoning_effort: pending[:effort])
811
+ conversation.persist_runtime_context! if conversation.respond_to?(:persist_runtime_context!)
812
+ end
813
+ true
814
+ end
815
+
816
+ def persist_reasoning_config(effort, provider: nil)
817
+ ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(provider || current_model_provider) => effort)
818
+ reload_client_config
819
+ end
820
+
642
821
  def reasoning_choices(choices, conversation = current_footer_conversation)
643
822
  current = (conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)).to_s
644
823
  choices.map do |effort, label|