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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
|
730
|
+
labels = session_picker_labels(sessions)
|
|
514
731
|
if @prompt.respond_to?(:select)
|
|
515
|
-
choice = @prompt.select(
|
|
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?
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -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 /\
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
"
|
|
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
|
|
251
|
-
ConfigFiles.
|
|
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
|
-
|
|
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
|
-
|
|
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|
|