kward 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +48 -2
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +1 -15
- data/doc/context-tools.md +70 -0
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +7 -8
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +80 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +260 -11
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +24 -6
- data/lib/kward/cli.rb +13 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -20
- 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 +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +1 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +22 -28
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +17 -6
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- 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 +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +256 -7
- 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 +91 -0
- data/templates/default/layout/html/layout.erb +39 -8
- data/templates/default/layout/html/setup.rb +33 -38
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -53,6 +53,7 @@ module Kward
|
|
|
53
53
|
#{command.call("kward init")} Install starter prompts and PRINCIPLES.md
|
|
54
54
|
#{command.call("kward doctor")} Check local Kward setup
|
|
55
55
|
#{command.call("kward sysprompt")} Inspect the effective system prompt
|
|
56
|
+
#{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
|
|
56
57
|
#{command.call("kward pan")} Start Pan mode web UI
|
|
57
58
|
#{command.call("kward rpc")} Start the experimental JSON-RPC backend
|
|
58
59
|
|
|
@@ -65,6 +66,7 @@ module Kward
|
|
|
65
66
|
#{command.call("doctor")} Check local Kward setup
|
|
66
67
|
#{command.call("sysprompt")} [--raw] Inspect the effective system prompt
|
|
67
68
|
#{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
|
|
69
|
+
#{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
|
|
68
70
|
#{command.call("pan")} Start Pan mode web UI
|
|
69
71
|
#{command.call("rpc")} Run the JSON-RPC backend for UI clients
|
|
70
72
|
|
|
@@ -78,6 +80,7 @@ module Kward
|
|
|
78
80
|
#{command.call("kward")} #{option.call('"Review this diff"')}
|
|
79
81
|
#{command.call("git diff | kward")} #{option.call('"Review this diff"')}
|
|
80
82
|
#{command.call("kward login openrouter")}
|
|
83
|
+
#{command.call("kward openrouter refresh")}
|
|
81
84
|
#{command.call("kward stats tokens today --bucket hour")}
|
|
82
85
|
|
|
83
86
|
Command names take precedence. Anything else is sent as a one-shot prompt.
|
|
@@ -126,6 +129,11 @@ module Kward
|
|
|
126
129
|
description: "Export local token telemetry as CSV.",
|
|
127
130
|
examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
|
|
128
131
|
},
|
|
132
|
+
"openrouter" => {
|
|
133
|
+
usage: "kward openrouter refresh|list",
|
|
134
|
+
description: "Refresh or list cached text-capable OpenRouter models available to your API key.",
|
|
135
|
+
examples: ["kward openrouter refresh", "kward openrouter --refresh", "kward openrouter list"]
|
|
136
|
+
},
|
|
129
137
|
"pan" => {
|
|
130
138
|
usage: "kward pan",
|
|
131
139
|
description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# OpenRouter cache management commands for the terminal CLI flow.
|
|
6
|
+
module OpenRouterCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_openrouter_command(arguments)
|
|
10
|
+
case arguments
|
|
11
|
+
when ["refresh"], ["--refresh"]
|
|
12
|
+
refresh_openrouter_models
|
|
13
|
+
when ["list"], ["--list"]
|
|
14
|
+
list_openrouter_models
|
|
15
|
+
else
|
|
16
|
+
raise ArgumentError, command_usage("openrouter")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def refresh_openrouter_models
|
|
21
|
+
cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
|
|
22
|
+
data = cache.refresh
|
|
23
|
+
count = Array(data["models"]).length
|
|
24
|
+
@client.reload_config if @client.respond_to?(:reload_config)
|
|
25
|
+
@prompt.say("Refreshed #{count} OpenRouter text model#{count == 1 ? "" : "s"} for this key.")
|
|
26
|
+
@prompt.say("Cached at: #{cache.path}")
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
warn e.message
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_openrouter_models
|
|
33
|
+
cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
|
|
34
|
+
data = cache.read
|
|
35
|
+
unless data
|
|
36
|
+
@prompt.say("No OpenRouter model cache found. Run `kward openrouter refresh` first.")
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
models = Array(data["models"])
|
|
41
|
+
lines = ["OpenRouter models cached at #{data["refreshed_at"]}:"]
|
|
42
|
+
lines.concat(models.map { |model| model["id"].to_s }.reject(&:empty?))
|
|
43
|
+
@prompt.say(lines.join("\n"))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def configured_openrouter_api_key
|
|
47
|
+
ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(ConfigFiles.read_config, "openrouter_api_key").to_s : ENV["OPENROUTER_API_KEY"].to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def openrouter_models_cache_path
|
|
51
|
+
File.join(File.dirname(ConfigFiles.config_path), "cache", "openrouter_models.json")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
1
3
|
# Namespace for the Kward CLI agent runtime.
|
|
2
4
|
module Kward
|
|
3
5
|
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
@@ -13,7 +15,6 @@ module Kward
|
|
|
13
15
|
prompt_interface = load_prompt_interface
|
|
14
16
|
return unless prompt_interface
|
|
15
17
|
|
|
16
|
-
banner_enabled = ConfigFiles.banner_enabled?
|
|
17
18
|
@prompt = prompt_interface.new(
|
|
18
19
|
slash_commands: slash_command_entries,
|
|
19
20
|
overlay_settings: ConfigFiles.overlay_settings,
|
|
@@ -22,8 +23,7 @@ module Kward
|
|
|
22
23
|
busy_help: ConfigFiles.composer_busy_help?,
|
|
23
24
|
attachment_badges: method(:composer_attachment_badges),
|
|
24
25
|
attachment_parser: method(:composer_attachment_parser),
|
|
25
|
-
|
|
26
|
-
banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
|
|
26
|
+
banner_message: Kward::PromptInterface::BANNER_MESSAGE
|
|
27
27
|
)
|
|
28
28
|
if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
|
|
29
29
|
@prompt.start(render: false)
|
|
@@ -50,9 +50,80 @@ module Kward
|
|
|
50
50
|
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# Writes the
|
|
53
|
+
# Writes the startup info screen output for the terminal CLI flow.
|
|
54
54
|
def print_visual_banner
|
|
55
|
-
|
|
55
|
+
return unless @prompt.respond_to?(:print_visual_banner)
|
|
56
|
+
|
|
57
|
+
@prompt.print_visual_banner(startup_info_screen)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def startup_info_screen
|
|
61
|
+
[
|
|
62
|
+
startup_status_line,
|
|
63
|
+
"",
|
|
64
|
+
startup_info_line("Workspace", startup_workspace_label),
|
|
65
|
+
startup_info_line("Branch", startup_branch_value),
|
|
66
|
+
startup_info_line("Plugins", startup_plugins_value),
|
|
67
|
+
"",
|
|
68
|
+
startup_brand_line
|
|
69
|
+
].join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def startup_workspace_label
|
|
73
|
+
root = File.expand_path(current_workspace_root)
|
|
74
|
+
home = begin
|
|
75
|
+
Dir.home
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
if home && (root == home || root.start_with?("#{home}/"))
|
|
80
|
+
relative = root.delete_prefix(home).sub(%r{\A/}, "")
|
|
81
|
+
return "~" if relative.empty?
|
|
82
|
+
return "~/#{relative}" unless relative.include?("/")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parent = File.basename(File.dirname(root))
|
|
86
|
+
name = File.basename(root)
|
|
87
|
+
parent.empty? || parent == "." ? name : "#{parent}/#{name}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def startup_branch_value
|
|
91
|
+
git_root = startup_git_root(current_workspace_root)
|
|
92
|
+
return "not a repository" if git_root.to_s.empty?
|
|
93
|
+
|
|
94
|
+
branch = startup_git_output(%w[git branch --show-current], root: git_root)
|
|
95
|
+
branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
|
|
96
|
+
branch.empty? ? "unknown" : branch
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def startup_plugins_value
|
|
100
|
+
filenames = plugin_registry.paths.map { |path| File.basename(path) }
|
|
101
|
+
filenames.empty? ? "none" : filenames.join(", ")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def startup_status_line
|
|
105
|
+
"#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def startup_info_line(label, value)
|
|
109
|
+
"#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def startup_brand_line
|
|
113
|
+
ANSI.colorize(Kward::PromptInterface::BANNER_MESSAGE, :bold, enabled: @color_enabled)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def startup_git_root(root)
|
|
117
|
+
startup_git_output(%w[git rev-parse --show-toplevel], root: root)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def startup_git_output(command, root:)
|
|
121
|
+
output, status = Open3.capture2e(*command, chdir: root.to_s)
|
|
122
|
+
return "" unless status.success?
|
|
123
|
+
|
|
124
|
+
output.lines.first.to_s.strip
|
|
125
|
+
rescue StandardError
|
|
126
|
+
""
|
|
56
127
|
end
|
|
57
128
|
|
|
58
129
|
def prompt_footer_renderer
|
|
@@ -107,7 +178,10 @@ module Kward
|
|
|
107
178
|
def composer_context_window(provider = nil, model = nil)
|
|
108
179
|
provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
|
|
109
180
|
model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
|
|
110
|
-
ModelInfo.
|
|
181
|
+
provider = ModelInfo.provider_label(provider)
|
|
182
|
+
return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
|
|
183
|
+
|
|
184
|
+
ModelInfo.context_window(provider, model)
|
|
111
185
|
end
|
|
112
186
|
|
|
113
187
|
def composer_context_usage(provider, model)
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -59,7 +59,7 @@ module Kward
|
|
|
59
59
|
if prompt_interface?
|
|
60
60
|
print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
61
61
|
else
|
|
62
|
-
@prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}
|
|
62
|
+
@prompt.say("\n#{colored("Tool>", *tool_label_styles(content))} #{tool_summary_display_text(summary)}\n")
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -71,7 +71,7 @@ module Kward
|
|
|
71
71
|
print_block_delta(label, rendered)
|
|
72
72
|
finish_stream_block
|
|
73
73
|
else
|
|
74
|
-
@prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}
|
|
74
|
+
@prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))} #{rendered}\n")
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -298,8 +298,9 @@ module Kward
|
|
|
298
298
|
def print_tool_result(tool_call, content, line_limit: nil)
|
|
299
299
|
summary = tool_result_summary(tool_call, content)
|
|
300
300
|
summary = limit_tool_output_lines(summary, line_limit) if line_limit
|
|
301
|
+
display_summary = tool_summary_display_text(summary)
|
|
301
302
|
if prompt_interface?
|
|
302
|
-
summary =
|
|
303
|
+
summary = display_summary.end_with?("\n") ? display_summary : "#{display_summary}\n"
|
|
303
304
|
if @prompt.respond_to?(:write_stream_block)
|
|
304
305
|
@prompt.write_stream_block("Tool", summary, finish: true)
|
|
305
306
|
else
|
|
@@ -309,18 +310,22 @@ module Kward
|
|
|
309
310
|
end
|
|
310
311
|
else
|
|
311
312
|
start_stream_block(tool_stream_label(content))
|
|
312
|
-
print
|
|
313
|
-
puts unless
|
|
313
|
+
print display_summary
|
|
314
|
+
puts unless display_summary.end_with?("\n")
|
|
314
315
|
$stdout.flush
|
|
315
316
|
@stream_block = nil
|
|
316
317
|
end
|
|
317
318
|
end
|
|
318
319
|
|
|
320
|
+
def tool_summary_display_text(summary)
|
|
321
|
+
summary.to_s.sub("\n", "\n\n")
|
|
322
|
+
end
|
|
323
|
+
|
|
319
324
|
def start_stream_block(label)
|
|
320
325
|
return if @stream_block == label
|
|
321
326
|
|
|
322
327
|
puts if @stream_block
|
|
323
|
-
|
|
328
|
+
print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
|
|
324
329
|
@stream_block = label
|
|
325
330
|
end
|
|
326
331
|
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -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,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.")
|